diff --git a/doc/source/advanced_query_operations.rst b/doc/source/advanced_query_operations.rst new file mode 100644 index 00000000..e602d479 --- /dev/null +++ b/doc/source/advanced_query_operations.rst @@ -0,0 +1,111 @@ +.. _Advanced query operations: + +========================= +Advanced query operations +========================= + +neomodel provides ways to enhance your queries beyond filtering and traversals. + +Annotate - Aliasing +------------------- + +The `annotate` method allows you to add transformations to your elements. To learn more about the available transformations, keep reading this section. + +Aggregations +------------ + +neomodel implements some of the aggregation methods available in Cypher: + +- Collect (with distinct option) +- Last + +These are usable in this way:: + + from neomodel.sync_.match import Collect, Last + + # distinct is optional, and defaults to False. When true, objects are deduplicated + Supplier.nodes.traverse_relations(available_species="coffees__species") + .annotate(Collect("available_species", distinct=True)) + .all() + + # Last is used to get the last element of a list + Supplier.nodes.traverse_relations(available_species="coffees__species") + .annotate(Last(Collect("last_species"))) + .all() + +Note how `annotate` is used to add the aggregation method to the query. + +.. note:: + Using the Last() method right after a Collect() without having set an ordering will return the last element in the list as it was returned by the database. + + This is probably not what you want ; which means you must provide an explicit ordering. To do so, you cannot use neomodel's `order_by` method, but need an intermediate transformation step (see below). + + This is because the order_by method adds ordering as the very last step of the Cypher query ; whereas in the present example, you want to first order Species, then get the last one, and then finally return your results. In other words, you need an intermediate WITH Cypher clause. + +Intermediate transformations +---------------------------- + +The `intermediate_transform` method basically allows you to add a WITH clause to your query. This is useful when you need to perform some operations on your results before returning them. + +As discussed in the note above, this is for example useful when you need to order your results before applying an aggregation method, like so:: + + from neomodel.sync_.match import Collect, Last + + # This will return all Coffee nodes, with their most expensive supplier + Coffee.nodes.traverse_relations(suppliers="suppliers") + .intermediate_transform( + {"suppliers": "suppliers"}, ordering=["suppliers.delivery_cost"] + ) + .annotate(supps=Last(Collect("suppliers"))) + +Subqueries +---------- + +The `subquery` method allows you to perform a `Cypher subquery `_ inside your query. This allows you to perform operations in isolation to the rest of your query:: + + from neomodel.sync_match import Collect, Last + + # This will create a CALL{} subquery + # And return a variable named supps usable in the rest of your query + Coffee.nodes.filter(name="Espresso") + .subquery( + Coffee.nodes.traverse_relations(suppliers="suppliers") + .intermediate_transform( + {"suppliers": "suppliers"}, ordering=["suppliers.delivery_cost"] + ) + .annotate(supps=Last(Collect("suppliers"))), + ["supps"], + ) + +.. note:: + Notice the subquery starts with Coffee.nodes ; neomodel will use this to know it needs to inject the source "coffee" variable generated by the outer query into the subquery. This means only Espresso coffee nodes will be considered in the subquery. + + We know this is confusing to read, but have not found a better wat to do this yet. If you have any suggestions, please let us know. + +Helpers +------- + +Reading the sections above, you may have noticed that we used explicit aliasing in the examples, as in:: + + traverse_relations(suppliers="suppliers") + +This allows you to reference the generated Cypher variables in your transformation steps, for example:: + + traverse_relations(suppliers="suppliers").annotate(Collect("suppliers")) + +In some cases though, it is not possible to set explicit aliases, for example when using `fetch_relations`. In these cases, neomodel provides `resolver` methods, so you do not have to guess the name of the variable in the generated Cypher. Those are `NodeNameResolver` and `RelationshipNameResolver`. For example:: + + from neomodel.sync_match import Collect, NodeNameResolver, RelationshipNameResolver + + Supplier.nodes.fetch_relations("coffees__species") + .annotate( + all_species=Collect(NodeNameResolver("coffees__species"), distinct=True), + all_species_rels=Collect( + RelationNameResolver("coffees__species"), distinct=True + ), + ) + .all() + +.. note:: + + When using the resolvers in combination with a traversal as in the example above, it will resolve the variable name of the last element in the traversal - the Species node for NodeNameResolver, and Coffee--Species relationship for RelationshipNameResolver. \ No newline at end of file diff --git a/doc/source/cypher.rst b/doc/source/cypher.rst index f8c7ccaf..8ce2a42e 100644 --- a/doc/source/cypher.rst +++ b/doc/source/cypher.rst @@ -24,6 +24,18 @@ Outside of a `StructuredNode`:: The ``resolve_objects`` parameter automatically inflates the returned nodes to their defined classes (this is turned **off** by default). See :ref:`automatic_class_resolution` for details and possible pitfalls. +You can also retrieve a whole path of already instantiated objects corresponding to +the nodes and relationship classes with a single query:: + + q = db.cypher_query("MATCH p=(:CityOfResidence)<-[:LIVES_IN]-(:PersonOfInterest)-[:IS_FROM]->(:CountryOfOrigin) RETURN p LIMIT 1", + resolve_objects = True) + +Notice here that ``resolve_objects`` is set to ``True``. This results in ``q`` being a +list of ``result, result_name`` and ``q[0][0][0]`` being a ``NeomodelPath`` object. + +``NeomodelPath`` ``nodes, relationships`` attributes contain already instantiated objects of the +nodes and relationships in the query, *in order of appearance*. + Integrations ============ diff --git a/doc/source/filtering_ordering.rst b/doc/source/filtering_ordering.rst new file mode 100644 index 00000000..41a37581 --- /dev/null +++ b/doc/source/filtering_ordering.rst @@ -0,0 +1,199 @@ +.. _Filtering and ordering: + +====================== +Filtering and ordering +====================== + +For the examples in this section, we will be using the following model:: + + class SupplierRel(StructuredRel): + since = DateTimeProperty(default=datetime.now) + + + class Supplier(StructuredNode): + name = StringProperty() + delivery_cost = IntegerProperty() + + + class Coffee(StructuredNode): + name = StringProperty(unique_index=True) + price = IntegerProperty() + suppliers = RelationshipFrom(Supplier, 'SUPPLIES', model=SupplierRel) + +Filtering +========= + +neomodel allows filtering on nodes' and relationships' properties. Filters can be combined using Django's Q syntax. It also allows multi-hop relationship traversals to filter on "remote" elements. + +Filter methods +-------------- + +The ``.nodes`` property of a class returns all nodes of that type from the database. + +This set (called `NodeSet`) can be iterated over and filtered on, using the `.filter` method:: + + # nodes with label Coffee whose price is greater than 2 + high_end_coffees = Coffee.nodes.filter(price__gt=2) + + try: + java = Coffee.nodes.get(name='Java') + except DoesNotExist: + # .filter will not throw an exception if no results are found + # but .get will + print("Couldn't find coffee 'Java'") + +The filter method borrows the same Django filter format with double underscore prefixed operators: + +- lt - less than +- gt - greater than +- lte - less than or equal to +- gte - greater than or equal to +- ne - not equal +- in - item in list +- isnull - `True` IS NULL, `False` IS NOT NULL +- exact - string equals +- iexact - string equals, case insensitive +- contains - contains string value +- icontains - contains string value, case insensitive +- startswith - starts with string value +- istartswith - starts with string value, case insensitive +- endswith - ends with string value +- iendswith - ends with string value, case insensitive +- regex - matches a regex expression +- iregex - matches a regex expression, case insensitive + +These operators work with both `.get` and `.filter` methods. + +Combining filters +----------------- + +The filter method allows you to combine multiple filters:: + + cheap_arabicas = Coffee.nodes.filter(price__lt=5, name__icontains='arabica') + +These filters are combined using the logical AND operator. To execute more complex logic (for example, queries with OR statements), `Q objects ` can be used. This is borrowed from Django. + +``Q`` objects can be combined using the ``&`` and ``|`` operators. Statements of arbitrary complexity can be composed by combining ``Q`` objects +with the ``&`` and ``|`` operators and use parenthetical grouping. Also, ``Q`` +objects can be negated using the ``~`` operator, allowing for combined lookups +that combine both a normal query and a negated (``NOT``) query:: + + Q(name__icontains='arabica') | ~Q(name__endswith='blend') + +Chaining ``Q`` objects will join them as an AND clause:: + + not_middle_priced_arabicas = Coffee.nodes.filter( + Q(name__icontains='arabica'), + Q(price__lt=5) | Q(price__gt=10) + ) + +Traversals and filtering +------------------------ + +Sometimes you need to filter nodes based on other nodes they are connected to. This can be done by including a traversal in the `filter` method. :: + + # Find all suppliers of coffee 'Java' who have been supplying since 2007 + # But whose prices are greater than 5 + since_date = datetime(2007, 1, 1) + java_old_timers = Coffee.nodes.filter( + name='Java', + suppliers__delivery_cost__gt=5, + **{"suppliers|since__lt": since_date} + ) + +In the example above, note the following syntax elements: + +- The name of relationships as defined in the `StructuredNode` class is used to traverse relationships. `suppliers` in this example. +- Double underscore `__` is used to target a property of a node. `delivery_cost` in this example. +- A pipe `|` is used to separate the relationship traversal from the property filter. The filter also has to included in a `**kwargs` dictionary, because the pipe character would break the syntax. This is a special syntax to indicate that the filter is on the relationship itself, not on the node at the end of the relationship. +- The filter operators like lt, gt, etc. can be used on the filtered property. + +Traversals can be of any length, with each relationships separated by a double underscore `__`, for example:: + + # country is here a relationship between Supplier and Country + Coffee.nodes.filter(suppliers__country__name='Brazil') + +Enforcing relationship/path existence +------------------------------------- + +The `has` method checks for existence of (one or more) relationships, in this case it returns a set of `Coffee` nodes which have a supplier:: + + Coffee.nodes.has(suppliers=True) + +This can be negated by setting `suppliers=False`, to find `Coffee` nodes without `suppliers`. + +You can also filter on the existence of more complex traversals by using the `traverse_relations` method. See :ref:`Path traversal`. + +Ordering +======== + +neomodel allows ordering by nodes' and relationships' properties. Order can be ascending or descending. Is also allows multi-hop relationship traversals to order on "remote" elements. Finally, you can inject raw Cypher clauses to have full control over ordering when necessary. + +order_by +-------- + +Ordering results by a particular property is done via the `order_by` method:: + + # Ascending sort + for coffee in Coffee.nodes.order_by('price'): + print(coffee, coffee.price) + + # Descending sort + for supplier in Supplier.nodes.order_by('-delivery_cost'): + print(supplier, supplier.delivery_cost) + + +Removing the ordering from a previously defined query, is done by passing `None` to `order_by`:: + + # Sort in descending order + suppliers = Supplier.nodes.order_by('-delivery_cost') + + # Don't order; yield nodes in the order neo4j returns them + suppliers = suppliers.order_by(None) + +For random ordering simply pass '?' to the order_by method:: + + Coffee.nodes.order_by('?') + +Traversals and ordering +----------------------- + +Sometimes you need to order results based on properties situated on different nodes or relationships. This can be done by including a traversal in the `order_by` method. :: + + # Find the most expensive coffee to deliver + # Then order by the date the supplier started supplying + Coffee.nodes.order_by( + '-suppliers__delivery_cost', + 'suppliers|since', + ) + +In the example above, note the following syntax elements: + +- The name of relationships as defined in the `StructuredNode` class is used to traverse relationships. `suppliers` in this example. +- Double underscore `__` is used to target a property of a node. `delivery_cost` in this example. +- A pipe `|` is used to separate the relationship traversal from the property filter. This is a special syntax to indicate that the filter is on the relationship itself, not on the node at the end of the relationship. + +Traversals can be of any length, with each relationships separated by a double underscore `__`, for example:: + + # country is here a relationship between Supplier and Country + Coffee.nodes.order_by('suppliers__country__latitude') + +RawCypher +--------- + +When you need more advanced ordering capabilities, for example to apply order to a transformed property, you can use the `RawCypher` method, like so:: + + from neomodel.sync_.match import RawCypher + + class SoftwareDependency(AsyncStructuredNode): + name = StringProperty() + version = StringProperty() + + SoftwareDependency(name="Package2", version="1.4.0").save() + SoftwareDependency(name="Package3", version="2.5.5").save() + + latest_dep = SoftwareDependency.nodes.order_by( + RawCypher("toInteger(split($n.version, '.')[0]) DESC"), + ) + +In the example above, note the `$n` placeholder in the `RawCypher` clause. This is a placeholder for the node being ordered (`SoftwareDependency` in this case). diff --git a/doc/source/getting_started.rst b/doc/source/getting_started.rst index 6e8a5aa0..dffa94ec 100644 --- a/doc/source/getting_started.rst +++ b/doc/source/getting_started.rst @@ -193,6 +193,28 @@ simply returning the node IDs rather than every attribute associated with that N # Return set of nodes people = Person.nodes.filter(age__gt=3) +Iteration, slicing and more +--------------------------- + +Iteration, slicing and counting is also supported:: + + # Iterable + for coffee in Coffee.nodes: + print coffee.name + + # Sliceable using python slice syntax + coffee = Coffee.nodes.filter(price__gt=2)[2:] + +The slice syntax returns a NodeSet object which can in turn be chained. + +Length and boolean methods do not return NodeSet objects and cannot be chained further:: + + # Count with __len__ + print len(Coffee.nodes.filter(price__gt=2)) + + if Coffee.nodes: + print "We have coffee nodes!" + Relationships ============= @@ -236,38 +258,6 @@ Working with relationships:: Retrieving additional relations =============================== -To avoid queries multiplication, you have the possibility to retrieve -additional relations with a single call:: - - # The following call will generate one MATCH with traversal per - # item in .fetch_relations() call - results = Person.nodes.fetch_relations('country').all() - for result in results: - print(result[0]) # Person - print(result[1]) # associated Country - -You can traverse more than one hop in your relations using the -following syntax:: - - # Go from person to City then Country - Person.nodes.fetch_relations('city__country').all() - -You can also force the use of an ``OPTIONAL MATCH`` statement using -the following syntax:: - - from neomodel.match import Optional - - results = Person.nodes.fetch_relations(Optional('country')).all() - -.. note:: - - Any relationship that you intend to traverse using this method **MUST have a model defined**, even if only the default StructuredRel, like:: - - class Person(StructuredNode): - country = RelationshipTo(Country, 'IS_FROM', model=StructuredRel) - - Otherwise, neomodel will not be able to determine which relationship model to resolve into, and will fail. - .. note:: You can fetch one or more relations within the same call @@ -339,3 +329,93 @@ Most _dunder_ methods for nodes and relationships had to be overriden to support # Sync equivalent - __getitem__ assert len(list(Coffee.nodes[1:])) == 2 + +Full example +============ + +The example below will show you how you can mix and match query operations, as described in :ref:`Filtering and ordering`, :ref:`Path traversal`, or :ref:`Advanced query operations`:: + + # These are the class definitions used for the query below + class HasCourseRel(AsyncStructuredRel): + level = StringProperty() + start_date = DateTimeProperty() + end_date = DateTimeProperty() + + + class Course(AsyncStructuredNode): + name = StringProperty() + + + class Building(AsyncStructuredNode): + name = StringProperty() + + + class Student(AsyncStructuredNode): + name = StringProperty() + + parents = AsyncRelationshipTo("Student", "HAS_PARENT", model=AsyncStructuredRel) + children = AsyncRelationshipFrom("Student", "HAS_PARENT", model=AsyncStructuredRel) + lives_in = AsyncRelationshipTo(Building, "LIVES_IN", model=AsyncStructuredRel) + courses = AsyncRelationshipTo(Course, "HAS_COURSE", model=HasCourseRel) + preferred_course = AsyncRelationshipTo( + Course, + "HAS_PREFERRED_COURSE", + model=AsyncStructuredRel, + cardinality=AsyncZeroOrOne, + ) + + # This is the query + full_nodeset = ( + await Student.nodes.filter(name__istartswith="m", lives_in__name="Eiffel Tower") # Combine filters + .order_by("name") + .fetch_relations( + "parents", + Optional("children__preferred_course"), + ) # Combine fetch_relations + .subquery( + Student.nodes.fetch_relations("courses") # Root variable student will be auto-injected here + .intermediate_transform( + {"rel": RelationNameResolver("courses")}, + ordering=[ + RawCypher("toInteger(split(rel.level, '.')[0])"), + RawCypher("toInteger(split(rel.level, '.')[1])"), + "rel.end_date", + "rel.start_date", + ], # Intermediate ordering + ) + .annotate( + latest_course=Last(Collect("rel")), + ), + ["latest_course"], + ) + ) + + # Using async, we need to do 2 await + # One is for subquery, the other is for resolve_subgraph + # It only runs a single Cypher query though + subgraph = await full_nodeset.annotate( + children=Collect(NodeNameResolver("children"), distinct=True), + children_preferred_course=Collect( + NodeNameResolver("children__preferred_course"), distinct=True + ), + ).resolve_subgraph() + + # The generated Cypher query looks like this + query = """ + MATCH (student:Student)-[r1:`HAS_PARENT`]->(student_parents:Student) + MATCH (student)-[r4:`LIVES_IN`]->(building_lives_in:Building) + OPTIONAL MATCH (student)<-[r2:`HAS_PARENT`]-(student_children:Student)-[r3:`HAS_PREFERRED_COURSE`]->(course_children__preferred_course:Course) + WITH * + # building_lives_in_name_1 = "Eiffel Tower" + # student_name_1 = "(?i)m.*" + WHERE building_lives_in.name = $building_lives_in_name_1 AND student.name =~ $student_name_1 + CALL { + WITH student + MATCH (student)-[r1:`HAS_COURSE`]->(course_courses:Course) + WITH r1 AS rel + ORDER BY toInteger(split(rel.level, '.')[0]),toInteger(split(rel.level, '.')[1]),rel.end_date,rel.start_date + RETURN last(collect(rel)) AS latest_course + } + RETURN latest_course, student, student_parents, r1, student_children, r2, course_children__preferred_course, r3, building_lives_in, r4, collect(DISTINCT student_children) AS children, collect(DISTINCT course_children__preferred_course) AS children_preferred_course + ORDER BY student.name + """ \ No newline at end of file diff --git a/doc/source/index.rst b/doc/source/index.rst index 91a728c0..068e2d93 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -74,7 +74,9 @@ Contents properties spatial_properties schema_management - queries + filtering_ordering + traversal + advanced_query_operations cypher transactions hooks diff --git a/doc/source/queries.rst b/doc/source/queries.rst deleted file mode 100644 index 4c77a791..00000000 --- a/doc/source/queries.rst +++ /dev/null @@ -1,258 +0,0 @@ -================ -Advanced queries -================ - -Neomodel contains an API for querying sets of nodes without having to write cypher:: - - class SupplierRel(StructuredRel): - since = DateTimeProperty(default=datetime.now) - - - class Supplier(StructuredNode): - name = StringProperty() - delivery_cost = IntegerProperty() - coffees = RelationshipTo('Coffee', 'SUPPLIES') - - - class Coffee(StructuredNode): - name = StringProperty(unique_index=True) - price = IntegerProperty() - suppliers = RelationshipFrom(Supplier, 'SUPPLIES', model=SupplierRel) - -Node sets and filtering -======================= - -The ``.nodes`` property of a class returns all nodes of that type from the database. - -This set (or `NodeSet`) can be iterated over and filtered on. Under the hood it uses labels introduced in Neo4J 2:: - - # nodes with label Coffee whose price is greater than 2 - Coffee.nodes.filter(price__gt=2) - - try: - java = Coffee.nodes.get(name='Java') - except Coffee.DoesNotExist: - print "Couldn't find coffee 'Java'" - -The filter method borrows the same Django filter format with double underscore prefixed operators: - -- lt - less than -- gt - greater than -- lte - less than or equal to -- gte - greater than or equal to -- ne - not equal -- in - item in list -- isnull - `True` IS NULL, `False` IS NOT NULL -- exact - string equals -- iexact - string equals, case insensitive -- contains - contains string value -- icontains - contains string value, case insensitive -- startswith - starts with string value -- istartswith - starts with string value, case insensitive -- endswith - ends with string value -- iendswith - ends with string value, case insensitive -- regex - matches a regex expression -- iregex - matches a regex expression, case insensitive - -Complex lookups with ``Q`` objects -================================== - -Keyword argument queries -- in `filter`, -etc. -- are "AND"ed together. To execute more complex queries (for -example, queries with ``OR`` statements), `Q objects ` can -be used. - -A `Q object` (``neomodel.Q``) is an object -used to encapsulate a collection of keyword arguments. These keyword arguments -are specified as in "Field lookups" above. - -For example, this ``Q`` object encapsulates a single ``LIKE`` query:: - - from neomodel import Q - Q(name__startswith='Py') - -``Q`` objects can be combined using the ``&`` and ``|`` operators. When an -operator is used on two ``Q`` objects, it yields a new ``Q`` object. - -For example, this statement yields a single ``Q`` object that represents the -"OR" of two ``"name__startswith"`` queries:: - - Q(name__startswith='Py') | Q(name__startswith='Jav') - -This is equivalent to the following SQL ``WHERE`` clause:: - - WHERE name STARTS WITH 'Py' OR name STARTS WITH 'Jav' - -Statements of arbitrary complexity can be composed by combining ``Q`` objects -with the ``&`` and ``|`` operators and use parenthetical grouping. Also, ``Q`` -objects can be negated using the ``~`` operator, allowing for combined lookups -that combine both a normal query and a negated (``NOT``) query:: - - Q(name__startswith='Py') | ~Q(year=2005) - -Each lookup function that takes keyword-arguments -(e.g. `filter`, `exclude`, `get`) can also be passed one or more -``Q`` objects as positional (not-named) arguments. If multiple -``Q`` object arguments are provided to a lookup function, the arguments will be "AND"ed -together. For example:: - - Lang.nodes.filter( - Q(name__startswith='Py'), - Q(year=2005) | Q(year=2006) - ) - -This roughly translates to the following Cypher query:: - - MATCH (lang:Lang) WHERE name STARTS WITH 'Py' - AND (year = 2005 OR year = 2006) - return lang; - -Lookup functions can mix the use of ``Q`` objects and keyword arguments. All -arguments provided to a lookup function (be they keyword arguments or ``Q`` -objects) are "AND"ed together. However, if a ``Q`` object is provided, it must -precede the definition of any keyword arguments. For example:: - - Lang.nodes.get( - Q(year=2005) | Q(year=2006), - name__startswith='Py', - ) - -This would be a valid query, equivalent to the previous example; - -Has a relationship -================== - -The `has` method checks for existence of (one or more) relationships, in this case it returns a set of `Coffee` nodes which have a supplier:: - - Coffee.nodes.has(suppliers=True) - -This can be negated by setting `suppliers=False`, to find `Coffee` nodes without `suppliers`. - -Iteration, slicing and more -=========================== - -Iteration, slicing and counting is also supported:: - - # Iterable - for coffee in Coffee.nodes: - print coffee.name - - # Sliceable using python slice syntax - coffee = Coffee.nodes.filter(price__gt=2)[2:] - -The slice syntax returns a NodeSet object which can in turn be chained. - -Length and boolean methods dont return NodeSet objects and cannot be chained further:: - - # Count with __len__ - print len(Coffee.nodes.filter(price__gt=2)) - - if Coffee.nodes: - print "We have coffee nodes!" - -Filtering by relationship properties -==================================== - -Filtering on relationship properties is also possible using the `match` method. Note that again these relationships must have a definition.:: - - coffee_brand = Coffee.nodes.get(name="BestCoffeeEver") - - for supplier in coffee_brand.suppliers.match(since_lt=january): - print(supplier.name) - -Ordering by property -==================== - -Ordering results by a particular property is done via th `order_by` method:: - - # Ascending sort - for coffee in Coffee.nodes.order_by('price'): - print(coffee, coffee.price) - - # Descending sort - for supplier in Supplier.nodes.order_by('-delivery_cost'): - print(supplier, supplier.delivery_cost) - - -Removing the ordering from a previously defined query, is done by passing `None` to `order_by`:: - - # Sort in descending order - suppliers = Supplier.nodes.order_by('-delivery_cost') - - # Don't order; yield nodes in the order neo4j returns them - suppliers = suppliers.order_by(None) - -For random ordering simply pass '?' to the order_by method:: - - Coffee.nodes.order_by('?') - -Retrieving paths -================ - -You can retrieve a whole path of already instantiated objects corresponding to -the nodes and relationship classes with a single query. - -Suppose the following schema: - -:: - - class PersonLivesInCity(StructuredRel): - some_num = IntegerProperty(index=True, - default=12) - - class CountryOfOrigin(StructuredNode): - code = StringProperty(unique_index=True, - required=True) - - class CityOfResidence(StructuredNode): - name = StringProperty(required=True) - country = RelationshipTo(CountryOfOrigin, - 'FROM_COUNTRY') - - class PersonOfInterest(StructuredNode): - uid = UniqueIdProperty() - name = StringProperty(unique_index=True) - age = IntegerProperty(index=True, - default=0) - - country = RelationshipTo(CountryOfOrigin, - 'IS_FROM') - city = RelationshipTo(CityOfResidence, - 'LIVES_IN', - model=PersonLivesInCity) - -Then, paths can be retrieved with: - -:: - - q = db.cypher_query("MATCH p=(:CityOfResidence)<-[:LIVES_IN]-(:PersonOfInterest)-[:IS_FROM]->(:CountryOfOrigin) RETURN p LIMIT 1", - resolve_objects = True) - -Notice here that ``resolve_objects`` is set to ``True``. This results in ``q`` being a -list of ``result, result_name`` and ``q[0][0][0]`` being a ``NeomodelPath`` object. - -``NeomodelPath`` ``nodes, relationships`` attributes contain already instantiated objects of the -nodes and relationships in the query, *in order of appearance*. - -It would be particularly useful to note here that each object is read exactly once from -the database. Therefore, nodes will be instantiated to their neomodel node objects and -relationships to their relationship models *if such a model exists*. In other words, -relationships with data (such as ``PersonLivesInCity`` above) will be instantiated to their -respective objects or ``StrucuredRel`` otherwise. Relationships do not "reload" their -end-points (unless this is required). - -Async neomodel - Caveats -======================== - -Python does not support async dunder methods. This means that we had to implement some overrides for those. -See the example below:: - - # This will not work as it uses the synchronous __bool__ method - assert await Customer.nodes.filter(prop="value") - - # Do this instead - assert await Customer.nodes.filter(prop="value").check_bool() - assert await Customer.nodes.filter(prop="value").check_nonzero() - - # Note : no changes are needed for sync so this still works : - assert Customer.nodes.filter(prop="value") diff --git a/doc/source/traversal.rst b/doc/source/traversal.rst new file mode 100644 index 00000000..c6347ad0 --- /dev/null +++ b/doc/source/traversal.rst @@ -0,0 +1,96 @@ +.. _Path traversal: + +============== +Path traversal +============== + +Neo4j is about traversing the graph, which means leveraging nodes and relations between them. This section will show you how to traverse the graph using neomodel. + +We will cover two methods : `traverse_relations` and `fetch_relations`. Those two methods are *mutually exclusive*, so you cannot chain them. + +For the examples in this section, we will be using the following model:: + + class Country(StructuredNode): + country_code = StringProperty(unique_index=True) + name = StringProperty() + + class Supplier(StructuredNode): + name = StringProperty() + delivery_cost = IntegerProperty() + country = RelationshipTo(Country, 'ESTABLISHED_IN') + + class Coffee(StructuredNode): + name = StringProperty(unique_index=True) + price = IntegerProperty() + suppliers = RelationshipFrom(Supplier, 'SUPPLIES') + +Traverse relations +------------------ + +The `traverse_relations` method allows you to filter on the existence of more complex traversals. For example, to find all `Coffee` nodes that have a supplier, and retrieve the country of that supplier, you can do:: + + Coffee.nodes.traverse_relations(country='suppliers__country').all() + +This will generate a Cypher MATCH clause that enforces the existence of at least one path like `Coffee<--Supplier-->Country`. + +The `Country` nodes matched will be made available for the rest of the query, with the variable name `country`. Note that this aliasing is optional. See :ref:`Advanced query operations` for examples of how to use this aliasing. + +.. note:: + + The `traverse_relations` method can be used to traverse multiple relationships, like:: + + Coffee.nodes.traverse_relations('suppliers__country', 'pub__city').all() + + This will generate a Cypher MATCH clause that enforces the existence of at least one path like `Coffee<--Supplier-->Country` and `Coffee<--Pub-->City`. + +Fetch relations +--------------- + +The syntax for `fetch_relations` is similar to `traverse_relations`, except that the generated Cypher will return all traversed objects (nodes and relations):: + + Coffee.nodes.fetch_relations(country='suppliers__country').all() + +.. note:: + + Any relationship that you intend to traverse using this method **MUST have a model defined**, even if only the default StructuredRel, like:: + + class Person(StructuredNode): + country = RelationshipTo(Country, 'IS_FROM', model=StructuredRel) + + Otherwise, neomodel will not be able to determine which relationship model to resolve into, and will fail. + +Optional match +-------------- + +With both `traverse_relations` and `fetch_relations`, you can force the use of an ``OPTIONAL MATCH`` statement using the following syntax:: + + from neomodel.match import Optional + + # Return the Person nodes, and if they have suppliers, return the suppliers as well + results = Coffee.nodes.fetch_relations(Optional('suppliers')).all() + +.. note:: + + You can fetch one or more relations within the same call + to `.fetch_relations()` and you can mix optional and non-optional + relations, like:: + + Person.nodes.fetch_relations('city__country', Optional('country')).all() + +Resolve results +--------------- + +By default, `fetch_relations` will return a list of tuples. If your path looks like ``(startNode:Coffee)<-[r1]-(middleNode:Supplier)-[r2]->(endNode:Country)``, +then you will get a list of results, where each result is a list of ``(startNode, r1, middleNode, r2, endNode)``. +These will be resolved by neomodel, so ``startNode`` will be a ``Coffee`` class as defined in neomodel for example. + +Using the `resolve_subgraph` method, you can get instead a list of "subgraphs", where each returned `StructuredNode` element will contain its relations and neighbour nodes. For example:: + + results = Coffee.nodes.fetch_relations('suppliers__country').resolve_subgraph().all() + +In this example, `results[0]` will be a `Coffee` object, with a `_relations` attribute. This will in turn have a `suppliers` and a `suppliers_relationship` attribute, which will contain the `Supplier` object and the relation object respectively. Recursively, the `Supplier` object will have a `country` attribute, which will contain the `Country` object. + +.. note:: + + The `resolve_subgraph` method is only available for `fetch_relations` queries. This is because `traverse_relations` queries do not return any relations, and thus there is no need to resolve them. + diff --git a/neomodel/async_/match.py b/neomodel/async_/match.py index b34938c6..6a2f9933 100644 --- a/neomodel/async_/match.py +++ b/neomodel/async_/match.py @@ -1499,18 +1499,17 @@ async def resolve_subgraph(self) -> list: raise RuntimeError( "Nothing to resolve. Make sure to include relations in the result using fetch_relations() or filter()." ) - all_nodes = qbuilder._execute(dict_output=True) other_nodes = {} root_node = None - async for row in all_nodes: + async for row in qbuilder._execute(dict_output=True): for name, node in row.items(): if node.__class__ is self.source and "_" not in name: root_node = node - else: - if isinstance(node, list) and isinstance(node[0], list): - other_nodes[name] = node[0] - else: - other_nodes[name] = node + continue + if isinstance(node, list) and isinstance(node[0], list): + other_nodes[name] = node[0] + continue + other_nodes[name] = node results.append( self._to_subgraph(root_node, other_nodes, qbuilder._ast.subgraph) ) diff --git a/neomodel/properties.py b/neomodel/properties.py index 8c848ea8..0cd716bd 100644 --- a/neomodel/properties.py +++ b/neomodel/properties.py @@ -418,7 +418,7 @@ class DateTimeFormatProperty(Property): """ Store a datetime by custom format :param default_now: If ``True``, the creation time (Local) will be used as default. - Defaults to ``False``. + Defaults to ``False``. :param format: Date format string, default is %Y-%m-%d :type default_now: :class:`bool` diff --git a/neomodel/sync_/match.py b/neomodel/sync_/match.py index 73715cc8..966b2601 100644 --- a/neomodel/sync_/match.py +++ b/neomodel/sync_/match.py @@ -1499,18 +1499,17 @@ def resolve_subgraph(self) -> list: raise RuntimeError( "Nothing to resolve. Make sure to include relations in the result using fetch_relations() or filter()." ) - all_nodes = qbuilder._execute(dict_output=True) other_nodes = {} root_node = None - for row in all_nodes: + for row in qbuilder._execute(dict_output=True): for name, node in row.items(): if node.__class__ is self.source and "_" not in name: root_node = node - else: - if isinstance(node, list) and isinstance(node[0], list): - other_nodes[name] = node[0] - else: - other_nodes[name] = node + continue + if isinstance(node, list) and isinstance(node[0], list): + other_nodes[name] = node[0] + continue + other_nodes[name] = node results.append( self._to_subgraph(root_node, other_nodes, qbuilder._ast.subgraph) ) diff --git a/pyproject.toml b/pyproject.toml index 3ad12f86..ce83e943 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ where = ["./"] [tool.pytest.ini_options] addopts = "--resetdb" testpaths = "test" +asyncio_default_fixture_loop_scope = "session" [tool.isort] profile = 'black' diff --git a/test/_async_compat/__init__.py b/test/_async_compat/__init__.py index 342678c3..5bdc28e3 100644 --- a/test/_async_compat/__init__.py +++ b/test/_async_compat/__init__.py @@ -1,8 +1,10 @@ from .mark_decorator import ( AsyncTestDecorators, TestDecorators, + mark_async_function_auto_fixture, mark_async_session_auto_fixture, mark_async_test, + mark_sync_function_auto_fixture, mark_sync_session_auto_fixture, mark_sync_test, ) @@ -13,5 +15,7 @@ "mark_sync_test", "TestDecorators", "mark_async_session_auto_fixture", + "mark_async_function_auto_fixture", "mark_sync_session_auto_fixture", + "mark_sync_function_auto_fixture", ] diff --git a/test/_async_compat/mark_decorator.py b/test/_async_compat/mark_decorator.py index a8c5eead..5d6050d8 100644 --- a/test/_async_compat/mark_decorator.py +++ b/test/_async_compat/mark_decorator.py @@ -1,9 +1,15 @@ import pytest import pytest_asyncio -mark_async_test = pytest.mark.asyncio -mark_async_session_auto_fixture = pytest_asyncio.fixture(scope="session", autouse=True) +mark_async_test = pytest.mark.asyncio(loop_scope="session") +mark_async_session_auto_fixture = pytest_asyncio.fixture( + loop_scope="session", scope="session", autouse=True +) +mark_async_function_auto_fixture = pytest_asyncio.fixture( + loop_scope="session", autouse=True +) mark_sync_session_auto_fixture = pytest.fixture(scope="session", autouse=True) +mark_sync_function_auto_fixture = pytest.fixture(autouse=True) def mark_sync_test(f): diff --git a/test/async_/conftest.py b/test/async_/conftest.py index 493ff12c..8cbf952b 100644 --- a/test/async_/conftest.py +++ b/test/async_/conftest.py @@ -1,15 +1,15 @@ -import asyncio import os import warnings -from test._async_compat import mark_async_session_auto_fixture - -import pytest +from test._async_compat import ( + mark_async_function_auto_fixture, + mark_async_session_auto_fixture, +) from neomodel import adb, config @mark_async_session_auto_fixture -async def setup_neo4j_session(request, event_loop): +async def setup_neo4j_session(request): """ Provides initial connection to the database and sets up the rest of the test suite @@ -44,17 +44,12 @@ async def setup_neo4j_session(request, event_loop): await adb.cypher_query("GRANT ROLE publisher TO troygreene") await adb.cypher_query("GRANT IMPERSONATE (troygreene) ON DBMS TO admin") - -@mark_async_session_auto_fixture -async def cleanup(event_loop): yield + await adb.close_connection() -@pytest.fixture(scope="session") -def event_loop(): - """Overrides pytest default function scoped event loop""" - policy = asyncio.get_event_loop_policy() - loop = policy.new_event_loop() - yield loop - loop.close() +@mark_async_function_auto_fixture +async def setUp(): + await adb.cypher_query("MATCH (n) DETACH DELETE n") + yield diff --git a/test/async_/test_issue283.py b/test/async_/test_issue283.py index 8106a796..ddbd6808 100644 --- a/test/async_/test_issue283.py +++ b/test/async_/test_issue283.py @@ -122,10 +122,6 @@ async def test_automatic_result_resolution(): # TechnicalPerson (!NOT basePerson!) assert type((await A.friends_with)[0]) is TechnicalPerson - await A.delete() - await B.delete() - await C.delete() - @mark_async_test async def test_recursive_automatic_result_resolution(): @@ -176,11 +172,6 @@ async def test_recursive_automatic_result_resolution(): # Assert that primitive data types remain primitive data types assert issubclass(type(L[0][0][0][1][0][1][0][1][0][0]), basestring) - await A.delete() - await B.delete() - await C.delete() - await D.delete() - @mark_async_test async def test_validation_with_inheritance_from_db(): @@ -240,12 +231,6 @@ async def test_validation_with_inheritance_from_db(): ) assert type((await D.friends_with)[0]) is PilotPerson - await A.delete() - await B.delete() - await C.delete() - await D.delete() - await E.delete() - @mark_async_test async def test_validation_enforcement_to_db(): @@ -295,13 +280,6 @@ async def test_validation_enforcement_to_db(): with pytest.raises(ValueError): await A.friends_with.connect(F) - await A.delete() - await B.delete() - await C.delete() - await D.delete() - await E.delete() - await F.delete() - @mark_async_test async def test_failed_result_resolution(): @@ -344,9 +322,6 @@ class RandomPerson(BasePerson): for some_friend in friends: print(some_friend.name) - await A.delete() - await B.delete() - @mark_async_test async def test_node_label_mismatch(): @@ -509,6 +484,10 @@ async def test_resolve_inexistent_relationship(): Attempting to resolve an inexistent relationship should raise an exception :return: """ + A = await TechnicalPerson(name="Michael Knight", expertise="Cars").save() + B = await TechnicalPerson(name="Luke Duke", expertise="Lasers").save() + + await A.friends_with.connect(B) # Forget about the FRIENDS_WITH Relationship. del adb._NODE_CLASS_REGISTRY[frozenset(["FRIENDS_WITH"])] @@ -518,7 +497,7 @@ async def test_resolve_inexistent_relationship(): match=r"Relationship of type .* does not resolve to any of the known objects.*", ): query_data = await adb.cypher_query( - "MATCH (:ExtendedSomePerson)-[r:FRIENDS_WITH]->(:ExtendedSomePerson) " + "MATCH (:TechnicalPerson)-[r:FRIENDS_WITH]->(:TechnicalPerson) " "RETURN DISTINCT r", resolve_objects=True, ) diff --git a/test/async_/test_issue600.py b/test/async_/test_issue600.py index 5f66f39e..3cf4e870 100644 --- a/test/async_/test_issue600.py +++ b/test/async_/test_issue600.py @@ -63,11 +63,6 @@ async def test_relationship_definer_second_sibling(): await B.rel_2.connect(C) await C.rel_3.connect(A) - # Clean up - await A.delete() - await B.delete() - await C.delete() - @mark_async_test async def test_relationship_definer_parent_last(): @@ -80,8 +75,3 @@ async def test_relationship_definer_parent_last(): await A.rel_1.connect(B) await B.rel_2.connect(C) await C.rel_3.connect(A) - - # Clean up - await A.delete() - await B.delete() - await C.delete() diff --git a/test/async_/test_match_api.py b/test/async_/test_match_api.py index 81944871..39e96957 100644 --- a/test/async_/test_match_api.py +++ b/test/async_/test_match_api.py @@ -11,6 +11,7 @@ AsyncRelationshipTo, AsyncStructuredNode, AsyncStructuredRel, + AsyncZeroOrOne, DateTimeProperty, IntegerProperty, Q, @@ -91,6 +92,35 @@ class SoftwareDependency(AsyncStructuredNode): version = StringProperty(required=True) +class HasCourseRel(AsyncStructuredRel): + level = StringProperty() + start_date = DateTimeProperty() + end_date = DateTimeProperty() + + +class Course(AsyncStructuredNode): + name = StringProperty() + + +class Building(AsyncStructuredNode): + name = StringProperty() + + +class Student(AsyncStructuredNode): + name = StringProperty() + + parents = AsyncRelationshipTo("Student", "HAS_PARENT", model=AsyncStructuredRel) + children = AsyncRelationshipFrom("Student", "HAS_PARENT", model=AsyncStructuredRel) + lives_in = AsyncRelationshipTo(Building, "LIVES_IN", model=AsyncStructuredRel) + courses = AsyncRelationshipTo(Course, "HAS_COURSE", model=HasCourseRel) + preferred_course = AsyncRelationshipTo( + Course, + "HAS_PREFERRED_COURSE", + model=AsyncStructuredRel, + cardinality=AsyncZeroOrOne, + ) + + @mark_async_test async def test_filter_exclude_via_labels(): await Coffee(name="Java", price=99).save() @@ -156,7 +186,7 @@ async def test_get(): @mark_async_test async def test_simple_traverse_with_filter(): nescafe = await Coffee(name="Nescafe2", price=99).save() - tesco = await Supplier(name="Sainsburys", delivery_cost=2).save() + tesco = await Supplier(name="Tesco", delivery_cost=2).save() await nescafe.suppliers.connect(tesco) qb = AsyncQueryBuilder( @@ -170,7 +200,7 @@ async def test_simple_traverse_with_filter(): assert qb._ast.match assert qb._ast.return_clause.startswith("suppliers") assert len(results) == 1 - assert results[0].name == "Sainsburys" + assert results[0].name == "Tesco" @mark_async_test @@ -223,9 +253,6 @@ async def test_len_and_iter_and_bool(): @mark_async_test async def test_slice(): - for c in await Coffee.nodes: - await c.delete() - await Coffee(name="Icelands finest").save() await Coffee(name="Britains finest").save() await Coffee(name="Japans finest").save() @@ -294,9 +321,6 @@ async def test_contains(): @mark_async_test async def test_order_by(): - # Clean DB before we start anything... - await adb.cypher_query("MATCH (n) DETACH DELETE n") - c1 = await Coffee(name="Icelands finest", price=5).save() c2 = await Coffee(name="Britains finest", price=10).save() c3 = await Coffee(name="Japans finest", price=35).save() @@ -339,9 +363,6 @@ async def test_order_by(): @mark_async_test async def test_order_by_rawcypher(): - # Clean DB before we start anything... - await adb.cypher_query("MATCH (n) DETACH DELETE n") - d1 = await SoftwareDependency(name="Package1", version="1.0.0").save() d2 = await SoftwareDependency(name="Package2", version="1.4.0").save() d3 = await SoftwareDependency(name="Package3", version="2.5.5").save() @@ -362,9 +383,6 @@ async def test_order_by_rawcypher(): @mark_async_test async def test_extra_filters(): - for c in await Coffee.nodes: - await c.delete() - c1 = await Coffee(name="Icelands finest", price=5, id_=1).save() c2 = await Coffee(name="Britains finest", price=10, id_=2).save() c3 = await Coffee(name="Japans finest", price=35, id_=3).save() @@ -436,10 +454,6 @@ async def test_empty_filters(): ``get_queryset`` function in ``GenericAPIView`` should returns ``NodeSet`` object. """ - - for c in await Coffee.nodes: - await c.delete() - c1 = await Coffee(name="Super", price=5, id_=1).save() c2 = await Coffee(name="Puper", price=10, id_=2).save() @@ -463,10 +477,6 @@ async def test_empty_filters(): @mark_async_test async def test_q_filters(): - # Test where no children and self.connector != conn ? - for c in await Coffee.nodes: - await c.delete() - c1 = await Coffee(name="Icelands finest", price=5, id_=1).save() c2 = await Coffee(name="Britains finest", price=10, id_=2).save() c3 = await Coffee(name="Japans finest", price=35, id_=3).save() @@ -557,7 +567,7 @@ async def test_traversal_filter_left_hand_statement(): nescafe = await Coffee(name="Nescafe2", price=99).save() nescafe_gold = await Coffee(name="Nescafe gold", price=11).save() - tesco = await Supplier(name="Sainsburys", delivery_cost=3).save() + tesco = await Supplier(name="Tesco", delivery_cost=3).save() biedronka = await Supplier(name="Biedronka", delivery_cost=5).save() lidl = await Supplier(name="Lidl", delivery_cost=3).save() @@ -576,14 +586,11 @@ async def test_traversal_filter_left_hand_statement(): @mark_async_test async def test_filter_with_traversal(): - # Clean DB before we start anything... - await adb.cypher_query("MATCH (n) DETACH DELETE n") - arabica = await Species(name="Arabica").save() robusta = await Species(name="Robusta").save() nescafe = await Coffee(name="Nescafe", price=11).save() nescafe_gold = await Coffee(name="Nescafe Gold", price=99).save() - tesco = await Supplier(name="Sainsburys", delivery_cost=3).save() + tesco = await Supplier(name="Tesco", delivery_cost=3).save() await nescafe.suppliers.connect(tesco) await nescafe_gold.suppliers.connect(tesco) await nescafe.species.connect(arabica) @@ -594,12 +601,18 @@ async def test_filter_with_traversal(): assert len(results[0]) == 3 assert results[0][0] == nescafe + results_multi_hop = await Supplier.nodes.filter( + coffees__species__name="Arabica" + ).all() + assert len(results_multi_hop) == 1 + assert results_multi_hop[0][0] == tesco + + no_results = await Supplier.nodes.filter(coffees__species__name="Noffee").all() + assert no_results == [] + @mark_async_test async def test_relation_prop_filtering(): - # Clean DB before we start anything... - await adb.cypher_query("MATCH (n) DETACH DELETE n") - arabica = await Species(name="Arabica").save() nescafe = await Coffee(name="Nescafe", price=99).save() supplier1 = await Supplier(name="Supplier 1", delivery_cost=3).save() @@ -616,12 +629,19 @@ async def test_relation_prop_filtering(): assert len(results) == 1 assert results[0][0] == supplier1 + # Test it works with mixed argument syntaxes + results2 = await Supplier.nodes.filter( + name="Supplier 1", + coffees__name="Nescafe", + **{"coffees|since__gt": datetime(2018, 4, 1, 0, 0)}, + ).all() + + assert len(results2) == 1 + assert results2[0][0] == supplier1 + @mark_async_test async def test_relation_prop_ordering(): - # Clean DB before we start anything... - await adb.cypher_query("MATCH (n) DETACH DELETE n") - arabica = await Species(name="Arabica").save() nescafe = await Coffee(name="Nescafe", price=99).save() supplier1 = await Supplier(name="Supplier 1", delivery_cost=3).save() @@ -648,21 +668,18 @@ async def test_relation_prop_ordering(): @mark_async_test async def test_fetch_relations(): - # Clean DB before we start anything... - await adb.cypher_query("MATCH (n) DETACH DELETE n") - arabica = await Species(name="Arabica").save() robusta = await Species(name="Robusta").save() nescafe = await Coffee(name="Nescafe", price=99).save() nescafe_gold = await Coffee(name="Nescafe Gold", price=11).save() - tesco = await Supplier(name="Sainsburys", delivery_cost=3).save() + tesco = await Supplier(name="Tesco", delivery_cost=3).save() await nescafe.suppliers.connect(tesco) await nescafe_gold.suppliers.connect(tesco) await nescafe.species.connect(arabica) result = ( - await Supplier.nodes.filter(name="Sainsburys") + await Supplier.nodes.filter(name="Tesco") .fetch_relations("coffees__species") .all() ) @@ -682,7 +699,7 @@ async def test_fetch_relations(): if AsyncUtil.is_async_code: count = ( - await Supplier.nodes.filter(name="Sainsburys") + await Supplier.nodes.filter(name="Tesco") .fetch_relations("coffees__species") .get_len() ) @@ -690,32 +707,29 @@ async def test_fetch_relations(): assert ( await Supplier.nodes.fetch_relations("coffees__species") - .filter(name="Sainsburys") + .filter(name="Tesco") .check_contains(tesco) ) else: count = len( - Supplier.nodes.filter(name="Sainsburys") + Supplier.nodes.filter(name="Tesco") .fetch_relations("coffees__species") .all() ) assert count == 1 assert tesco in Supplier.nodes.fetch_relations("coffees__species").filter( - name="Sainsburys" + name="Tesco" ) @mark_async_test async def test_traverse_and_order_by(): - # Clean DB before we start anything... - await adb.cypher_query("MATCH (n) DETACH DELETE n") - arabica = await Species(name="Arabica").save() robusta = await Species(name="Robusta").save() nescafe = await Coffee(name="Nescafe", price=99).save() nescafe_gold = await Coffee(name="Nescafe Gold", price=110).save() - tesco = await Supplier(name="Sainsburys", delivery_cost=3).save() + tesco = await Supplier(name="Tesco", delivery_cost=3).save() await nescafe.suppliers.connect(tesco) await nescafe_gold.suppliers.connect(tesco) await nescafe.species.connect(arabica) @@ -732,15 +746,12 @@ async def test_traverse_and_order_by(): @mark_async_test async def test_annotate_and_collect(): - # Clean DB before we start anything... - await adb.cypher_query("MATCH (n) DETACH DELETE n") - arabica = await Species(name="Arabica").save() robusta = await Species(name="Robusta").save() nescafe = await Coffee(name="Nescafe 1002", price=99).save() nescafe_gold = await Coffee(name="Nescafe 1003", price=11).save() - tesco = await Supplier(name="Sainsburys", delivery_cost=3).save() + tesco = await Supplier(name="Tesco", delivery_cost=3).save() await nescafe.suppliers.connect(tesco) await nescafe_gold.suppliers.connect(tesco) await nescafe.species.connect(arabica) @@ -785,15 +796,12 @@ async def test_annotate_and_collect(): @mark_async_test async def test_resolve_subgraph(): - # Clean DB before we start anything... - await adb.cypher_query("MATCH (n) DETACH DELETE n") - arabica = await Species(name="Arabica").save() robusta = await Species(name="Robusta").save() nescafe = await Coffee(name="Nescafe", price=99).save() nescafe_gold = await Coffee(name="Nescafe Gold", price=11).save() - tesco = await Supplier(name="Sainsburys", delivery_cost=3).save() + tesco = await Supplier(name="Tesco", delivery_cost=3).save() await nescafe.suppliers.connect(tesco) await nescafe_gold.suppliers.connect(tesco) await nescafe.species.connect(arabica) @@ -835,14 +843,11 @@ async def test_resolve_subgraph(): @mark_async_test async def test_resolve_subgraph_optional(): - # Clean DB before we start anything... - await adb.cypher_query("MATCH (n) DETACH DELETE n") - arabica = await Species(name="Arabica").save() nescafe = await Coffee(name="Nescafe", price=99).save() nescafe_gold = await Coffee(name="Nescafe Gold", price=11).save() - tesco = await Supplier(name="Sainsburys", delivery_cost=3).save() + tesco = await Supplier(name="Tesco", delivery_cost=3).save() await nescafe.suppliers.connect(tesco) await nescafe_gold.suppliers.connect(tesco) await nescafe.species.connect(arabica) @@ -862,9 +867,6 @@ async def test_resolve_subgraph_optional(): @mark_async_test async def test_subquery(): - # Clean DB before we start anything... - await adb.cypher_query("MATCH (n) DETACH DELETE n") - arabica = await Species(name="Arabica").save() nescafe = await Coffee(name="Nescafe", price=99).save() supplier1 = await Supplier(name="Supplier 1", delivery_cost=3).save() @@ -901,9 +903,6 @@ async def test_subquery(): @mark_async_test async def test_intermediate_transform(): - # Clean DB before we start anything... - await adb.cypher_query("MATCH (n) DETACH DELETE n") - arabica = await Species(name="Arabica").save() nescafe = await Coffee(name="Nescafe", price=99).save() supplier1 = await Supplier(name="Supplier 1", delivery_cost=3).save() @@ -952,6 +951,95 @@ async def test_intermediate_transform(): ) +@mark_async_test +async def test_mix_functions(): + # Test with a mix of all advanced querying functions + + eiffel_tower = await Building(name="Eiffel Tower").save() + empire_state_building = await Building(name="Empire State Building").save() + miranda = await Student(name="Miranda").save() + await miranda.lives_in.connect(empire_state_building) + jean_pierre = await Student(name="Jean-Pierre").save() + await jean_pierre.lives_in.connect(eiffel_tower) + mireille = await Student(name="Mireille").save() + mimoun_jr = await Student(name="Mimoun Jr").save() + mimoun = await Student(name="Mimoun").save() + await mireille.lives_in.connect(eiffel_tower) + await mimoun_jr.lives_in.connect(eiffel_tower) + await mimoun.lives_in.connect(eiffel_tower) + await mimoun.parents.connect(mireille) + await mimoun.children.connect(mimoun_jr) + math = await Course(name="Math").save() + dessin = await Course(name="Dessin").save() + await mimoun.courses.connect( + math, + { + "level": "1.2", + "start_date": datetime(2020, 6, 2), + "end_date": datetime(2020, 12, 31), + }, + ) + await mimoun.courses.connect( + math, + { + "level": "1.1", + "start_date": datetime(2020, 1, 1), + "end_date": datetime(2020, 6, 1), + }, + ) + await mimoun_jr.courses.connect( + math, + { + "level": "1.1", + "start_date": datetime(2020, 1, 1), + "end_date": datetime(2020, 6, 1), + }, + ) + + await mimoun_jr.preferred_course.connect(dessin) + + full_nodeset = ( + await Student.nodes.filter(name__istartswith="m", lives_in__name="Eiffel Tower") + .order_by("name") + .fetch_relations( + "parents", + Optional("children__preferred_course"), + ) + .subquery( + Student.nodes.fetch_relations("courses") + .intermediate_transform( + {"rel": RelationNameResolver("courses")}, + ordering=[ + RawCypher("toInteger(split(rel.level, '.')[0])"), + RawCypher("toInteger(split(rel.level, '.')[1])"), + "rel.end_date", + "rel.start_date", + ], + ) + .annotate( + latest_course=Last(Collect("rel")), + ), + ["latest_course"], + ) + ) + + subgraph = await full_nodeset.annotate( + children=Collect(NodeNameResolver("children"), distinct=True), + children_preferred_course=Collect( + NodeNameResolver("children__preferred_course"), distinct=True + ), + ).resolve_subgraph() + + assert len(subgraph) == 2 + assert subgraph[0] == mimoun + assert subgraph[1] == mimoun_jr + mimoun_returned_rels = subgraph[0]._relations + assert mimoun_returned_rels["children"] == mimoun_jr + assert mimoun_returned_rels["children"]._relations["preferred_course"] == dessin + assert mimoun_returned_rels["parents"] == mireille + assert mimoun_returned_rels["latest_course_relationship"].level == "1.2" + + @mark_async_test async def test_issue_795(): jim = await PersonX(name="Jim", age=3).save() # Create @@ -995,9 +1083,6 @@ async def test_in_filter_with_array_property(): async def test_async_iterator(): n = 10 if AsyncUtil.is_async_code: - for c in await Coffee.nodes: - await c.delete() - for i in range(n): await Coffee(name=f"xxx_{i}", price=i).save() diff --git a/test/async_/test_paths.py b/test/async_/test_paths.py index 59a5e385..f0599e01 100644 --- a/test/async_/test_paths.py +++ b/test/async_/test_paths.py @@ -85,12 +85,3 @@ async def test_path_instantiation(): assert type(path_rels[0]) is PersonLivesInCity assert type(path_rels[1]) is AsyncStructuredRel - - await c1.delete() - await c2.delete() - await ct1.delete() - await ct2.delete() - await p1.delete() - await p2.delete() - await p3.delete() - await p4.delete() diff --git a/test/async_/test_properties.py b/test/async_/test_properties.py index 4f3eab2d..1e8f0c44 100644 --- a/test/async_/test_properties.py +++ b/test/async_/test_properties.py @@ -418,10 +418,6 @@ async def test_independent_property_name(): rel = await x.knows.relationship(x) assert rel.known_for == r.known_for - # -- cleanup -- - - await x.delete() - @mark_async_test async def test_independent_property_name_for_semi_structured(): @@ -455,8 +451,6 @@ class TestDBNamePropertySemiStructuredNode(AsyncSemiStructuredNode): # assert not hasattr(from_get, "title") assert from_get.extra == "data" - await semi.delete() - @mark_async_test async def test_independent_property_name_get_or_create(): @@ -475,9 +469,6 @@ class TestNode(AsyncStructuredNode): assert node_properties["name"] == "jim" assert "name_" not in node_properties - # delete node afterwards - await x.delete() - @mark.parametrize("normalized_class", (NormalizedProperty,)) def test_normalized_property(normalized_class): @@ -648,9 +639,6 @@ class ConstrainedTestNode(AsyncStructuredNode): node_properties = get_graph_entity_properties(results[0][0]) assert node_properties["unique_required_property"] == "unique and required" - # delete node afterwards - await x.delete() - @mark_async_test async def test_unique_index_prop_enforced(): @@ -675,11 +663,6 @@ class UniqueNullableNameNode(AsyncStructuredNode): results, _ = await adb.cypher_query("MATCH (n:UniqueNullableNameNode) RETURN n") assert len(results) == 3 - # Delete nodes afterwards - await x.delete() - await y.delete() - await z.delete() - def test_alias_property(): class AliasedClass(AsyncStructuredNode): diff --git a/test/async_/test_transactions.py b/test/async_/test_transactions.py index 59d523c5..de7a13e5 100644 --- a/test/async_/test_transactions.py +++ b/test/async_/test_transactions.py @@ -14,9 +14,6 @@ class APerson(AsyncStructuredNode): @mark_async_test async def test_rollback_and_commit_transaction(): - for p in await APerson.nodes: - await p.delete() - await APerson(name="Roger").save() await adb.begin() @@ -41,8 +38,6 @@ async def in_a_tx(*names): @mark_async_test async def test_transaction_decorator(): await adb.install_labels(APerson) - for p in await APerson.nodes: - await p.delete() # should work await in_a_tx("Roger") @@ -68,9 +63,6 @@ async def test_transaction_as_a_context(): @mark_async_test async def test_query_inside_transaction(): - for p in await APerson.nodes: - await p.delete() - async with adb.transaction: await APerson(name="Alice").save() await APerson(name="Bob").save() @@ -119,9 +111,6 @@ async def in_a_tx_with_bookmark(*names): @mark_async_test async def test_bookmark_transaction_decorator(): - for p in await APerson.nodes: - await p.delete() - # should work result, bookmarks = await in_a_tx_with_bookmark("Ruth", bookmarks=None) assert result is None @@ -181,9 +170,6 @@ async def test_bookmark_passed_in_to_context(spy_on_db_begin): @mark_async_test async def test_query_inside_bookmark_transaction(): - for p in await APerson.nodes: - await p.delete() - async with adb.transaction as transaction: await APerson(name="Alice").save() await APerson(name="Bob").save() diff --git a/test/sync_/conftest.py b/test/sync_/conftest.py index d2cd787e..cbe38140 100644 --- a/test/sync_/conftest.py +++ b/test/sync_/conftest.py @@ -1,6 +1,9 @@ import os import warnings -from test._async_compat import mark_sync_session_auto_fixture +from test._async_compat import ( + mark_async_function_auto_fixture, + mark_sync_session_auto_fixture, +) from neomodel import config, db @@ -41,8 +44,12 @@ def setup_neo4j_session(request): db.cypher_query("GRANT ROLE publisher TO troygreene") db.cypher_query("GRANT IMPERSONATE (troygreene) ON DBMS TO admin") - -@mark_sync_session_auto_fixture -def cleanup(): yield + db.close_connection() + + +@mark_async_function_auto_fixture +def setUp(): + db.cypher_query("MATCH (n) DETACH DELETE n") + yield diff --git a/test/sync_/test_issue283.py b/test/sync_/test_issue283.py index 611431ce..fab4f0d7 100644 --- a/test/sync_/test_issue283.py +++ b/test/sync_/test_issue283.py @@ -117,10 +117,6 @@ def test_automatic_result_resolution(): # TechnicalPerson (!NOT basePerson!) assert type((A.friends_with)[0]) is TechnicalPerson - A.delete() - B.delete() - C.delete() - @mark_sync_test def test_recursive_automatic_result_resolution(): @@ -159,11 +155,6 @@ def test_recursive_automatic_result_resolution(): # Assert that primitive data types remain primitive data types assert issubclass(type(L[0][0][0][1][0][1][0][1][0][0]), basestring) - A.delete() - B.delete() - C.delete() - D.delete() - @mark_sync_test def test_validation_with_inheritance_from_db(): @@ -217,12 +208,6 @@ def test_validation_with_inheritance_from_db(): ) assert type((D.friends_with)[0]) is PilotPerson - A.delete() - B.delete() - C.delete() - D.delete() - E.delete() - @mark_sync_test def test_validation_enforcement_to_db(): @@ -266,13 +251,6 @@ def test_validation_enforcement_to_db(): with pytest.raises(ValueError): A.friends_with.connect(F) - A.delete() - B.delete() - C.delete() - D.delete() - E.delete() - F.delete() - @mark_sync_test def test_failed_result_resolution(): @@ -311,9 +289,6 @@ class RandomPerson(BasePerson): for some_friend in friends: print(some_friend.name) - A.delete() - B.delete() - @mark_sync_test def test_node_label_mismatch(): @@ -470,6 +445,10 @@ def test_resolve_inexistent_relationship(): Attempting to resolve an inexistent relationship should raise an exception :return: """ + A = TechnicalPerson(name="Michael Knight", expertise="Cars").save() + B = TechnicalPerson(name="Luke Duke", expertise="Lasers").save() + + A.friends_with.connect(B) # Forget about the FRIENDS_WITH Relationship. del db._NODE_CLASS_REGISTRY[frozenset(["FRIENDS_WITH"])] @@ -479,7 +458,7 @@ def test_resolve_inexistent_relationship(): match=r"Relationship of type .* does not resolve to any of the known objects.*", ): query_data = db.cypher_query( - "MATCH (:ExtendedSomePerson)-[r:FRIENDS_WITH]->(:ExtendedSomePerson) " + "MATCH (:TechnicalPerson)-[r:FRIENDS_WITH]->(:TechnicalPerson) " "RETURN DISTINCT r", resolve_objects=True, ) diff --git a/test/sync_/test_issue600.py b/test/sync_/test_issue600.py index f6b5a10b..181a156d 100644 --- a/test/sync_/test_issue600.py +++ b/test/sync_/test_issue600.py @@ -63,11 +63,6 @@ def test_relationship_definer_second_sibling(): B.rel_2.connect(C) C.rel_3.connect(A) - # Clean up - A.delete() - B.delete() - C.delete() - @mark_sync_test def test_relationship_definer_parent_last(): @@ -80,8 +75,3 @@ def test_relationship_definer_parent_last(): A.rel_1.connect(B) B.rel_2.connect(C) C.rel_3.connect(A) - - # Clean up - A.delete() - B.delete() - C.delete() diff --git a/test/sync_/test_match_api.py b/test/sync_/test_match_api.py index e47e3396..78909860 100644 --- a/test/sync_/test_match_api.py +++ b/test/sync_/test_match_api.py @@ -16,6 +16,7 @@ StructuredNode, StructuredRel, UniqueIdProperty, + ZeroOrOne, db, ) from neomodel._async_compat.util import Util @@ -89,6 +90,35 @@ class SoftwareDependency(StructuredNode): version = StringProperty(required=True) +class HasCourseRel(StructuredRel): + level = StringProperty() + start_date = DateTimeProperty() + end_date = DateTimeProperty() + + +class Course(StructuredNode): + name = StringProperty() + + +class Building(StructuredNode): + name = StringProperty() + + +class Student(StructuredNode): + name = StringProperty() + + parents = RelationshipTo("Student", "HAS_PARENT", model=StructuredRel) + children = RelationshipFrom("Student", "HAS_PARENT", model=StructuredRel) + lives_in = RelationshipTo(Building, "LIVES_IN", model=StructuredRel) + courses = RelationshipTo(Course, "HAS_COURSE", model=HasCourseRel) + preferred_course = RelationshipTo( + Course, + "HAS_PREFERRED_COURSE", + model=StructuredRel, + cardinality=ZeroOrOne, + ) + + @mark_sync_test def test_filter_exclude_via_labels(): Coffee(name="Java", price=99).save() @@ -154,7 +184,7 @@ def test_get(): @mark_sync_test def test_simple_traverse_with_filter(): nescafe = Coffee(name="Nescafe2", price=99).save() - tesco = Supplier(name="Sainsburys", delivery_cost=2).save() + tesco = Supplier(name="Tesco", delivery_cost=2).save() nescafe.suppliers.connect(tesco) qb = QueryBuilder(NodeSet(source=nescafe).suppliers.match(since__lt=datetime.now())) @@ -166,7 +196,7 @@ def test_simple_traverse_with_filter(): assert qb._ast.match assert qb._ast.return_clause.startswith("suppliers") assert len(results) == 1 - assert results[0].name == "Sainsburys" + assert results[0].name == "Tesco" @mark_sync_test @@ -219,9 +249,6 @@ def test_len_and_iter_and_bool(): @mark_sync_test def test_slice(): - for c in Coffee.nodes: - c.delete() - Coffee(name="Icelands finest").save() Coffee(name="Britains finest").save() Coffee(name="Japans finest").save() @@ -290,9 +317,6 @@ def test_contains(): @mark_sync_test def test_order_by(): - # Clean DB before we start anything... - db.cypher_query("MATCH (n) DETACH DELETE n") - c1 = Coffee(name="Icelands finest", price=5).save() c2 = Coffee(name="Britains finest", price=10).save() c3 = Coffee(name="Japans finest", price=35).save() @@ -335,9 +359,6 @@ def test_order_by(): @mark_sync_test def test_order_by_rawcypher(): - # Clean DB before we start anything... - db.cypher_query("MATCH (n) DETACH DELETE n") - d1 = SoftwareDependency(name="Package1", version="1.0.0").save() d2 = SoftwareDependency(name="Package2", version="1.4.0").save() d3 = SoftwareDependency(name="Package3", version="2.5.5").save() @@ -358,9 +379,6 @@ def test_order_by_rawcypher(): @mark_sync_test def test_extra_filters(): - for c in Coffee.nodes: - c.delete() - c1 = Coffee(name="Icelands finest", price=5, id_=1).save() c2 = Coffee(name="Britains finest", price=10, id_=2).save() c3 = Coffee(name="Japans finest", price=35, id_=3).save() @@ -432,10 +450,6 @@ def test_empty_filters(): ``get_queryset`` function in ``GenericAPIView`` should returns ``NodeSet`` object. """ - - for c in Coffee.nodes: - c.delete() - c1 = Coffee(name="Super", price=5, id_=1).save() c2 = Coffee(name="Puper", price=10, id_=2).save() @@ -459,10 +473,6 @@ def test_empty_filters(): @mark_sync_test def test_q_filters(): - # Test where no children and self.connector != conn ? - for c in Coffee.nodes: - c.delete() - c1 = Coffee(name="Icelands finest", price=5, id_=1).save() c2 = Coffee(name="Britains finest", price=10, id_=2).save() c3 = Coffee(name="Japans finest", price=35, id_=3).save() @@ -553,7 +563,7 @@ def test_traversal_filter_left_hand_statement(): nescafe = Coffee(name="Nescafe2", price=99).save() nescafe_gold = Coffee(name="Nescafe gold", price=11).save() - tesco = Supplier(name="Sainsburys", delivery_cost=3).save() + tesco = Supplier(name="Tesco", delivery_cost=3).save() biedronka = Supplier(name="Biedronka", delivery_cost=5).save() lidl = Supplier(name="Lidl", delivery_cost=3).save() @@ -570,14 +580,11 @@ def test_traversal_filter_left_hand_statement(): @mark_sync_test def test_filter_with_traversal(): - # Clean DB before we start anything... - db.cypher_query("MATCH (n) DETACH DELETE n") - arabica = Species(name="Arabica").save() robusta = Species(name="Robusta").save() nescafe = Coffee(name="Nescafe", price=11).save() nescafe_gold = Coffee(name="Nescafe Gold", price=99).save() - tesco = Supplier(name="Sainsburys", delivery_cost=3).save() + tesco = Supplier(name="Tesco", delivery_cost=3).save() nescafe.suppliers.connect(tesco) nescafe_gold.suppliers.connect(tesco) nescafe.species.connect(arabica) @@ -588,12 +595,16 @@ def test_filter_with_traversal(): assert len(results[0]) == 3 assert results[0][0] == nescafe + results_multi_hop = Supplier.nodes.filter(coffees__species__name="Arabica").all() + assert len(results_multi_hop) == 1 + assert results_multi_hop[0][0] == tesco + + no_results = Supplier.nodes.filter(coffees__species__name="Noffee").all() + assert no_results == [] + @mark_sync_test def test_relation_prop_filtering(): - # Clean DB before we start anything... - db.cypher_query("MATCH (n) DETACH DELETE n") - arabica = Species(name="Arabica").save() nescafe = Coffee(name="Nescafe", price=99).save() supplier1 = Supplier(name="Supplier 1", delivery_cost=3).save() @@ -610,12 +621,19 @@ def test_relation_prop_filtering(): assert len(results) == 1 assert results[0][0] == supplier1 + # Test it works with mixed argument syntaxes + results2 = Supplier.nodes.filter( + name="Supplier 1", + coffees__name="Nescafe", + **{"coffees|since__gt": datetime(2018, 4, 1, 0, 0)}, + ).all() + + assert len(results2) == 1 + assert results2[0][0] == supplier1 + @mark_sync_test def test_relation_prop_ordering(): - # Clean DB before we start anything... - db.cypher_query("MATCH (n) DETACH DELETE n") - arabica = Species(name="Arabica").save() nescafe = Coffee(name="Nescafe", price=99).save() supplier1 = Supplier(name="Supplier 1", delivery_cost=3).save() @@ -638,23 +656,18 @@ def test_relation_prop_ordering(): @mark_sync_test def test_fetch_relations(): - # Clean DB before we start anything... - db.cypher_query("MATCH (n) DETACH DELETE n") - arabica = Species(name="Arabica").save() robusta = Species(name="Robusta").save() nescafe = Coffee(name="Nescafe", price=99).save() nescafe_gold = Coffee(name="Nescafe Gold", price=11).save() - tesco = Supplier(name="Sainsburys", delivery_cost=3).save() + tesco = Supplier(name="Tesco", delivery_cost=3).save() nescafe.suppliers.connect(tesco) nescafe_gold.suppliers.connect(tesco) nescafe.species.connect(arabica) result = ( - Supplier.nodes.filter(name="Sainsburys") - .fetch_relations("coffees__species") - .all() + Supplier.nodes.filter(name="Tesco").fetch_relations("coffees__species").all() ) assert len(result[0]) == 5 assert arabica in result[0] @@ -672,7 +685,7 @@ def test_fetch_relations(): if Util.is_async_code: count = ( - Supplier.nodes.filter(name="Sainsburys") + Supplier.nodes.filter(name="Tesco") .fetch_relations("coffees__species") .__len__() ) @@ -680,32 +693,29 @@ def test_fetch_relations(): assert ( Supplier.nodes.fetch_relations("coffees__species") - .filter(name="Sainsburys") + .filter(name="Tesco") .__contains__(tesco) ) else: count = len( - Supplier.nodes.filter(name="Sainsburys") + Supplier.nodes.filter(name="Tesco") .fetch_relations("coffees__species") .all() ) assert count == 1 assert tesco in Supplier.nodes.fetch_relations("coffees__species").filter( - name="Sainsburys" + name="Tesco" ) @mark_sync_test def test_traverse_and_order_by(): - # Clean DB before we start anything... - db.cypher_query("MATCH (n) DETACH DELETE n") - arabica = Species(name="Arabica").save() robusta = Species(name="Robusta").save() nescafe = Coffee(name="Nescafe", price=99).save() nescafe_gold = Coffee(name="Nescafe Gold", price=110).save() - tesco = Supplier(name="Sainsburys", delivery_cost=3).save() + tesco = Supplier(name="Tesco", delivery_cost=3).save() nescafe.suppliers.connect(tesco) nescafe_gold.suppliers.connect(tesco) nescafe.species.connect(arabica) @@ -720,15 +730,12 @@ def test_traverse_and_order_by(): @mark_sync_test def test_annotate_and_collect(): - # Clean DB before we start anything... - db.cypher_query("MATCH (n) DETACH DELETE n") - arabica = Species(name="Arabica").save() robusta = Species(name="Robusta").save() nescafe = Coffee(name="Nescafe 1002", price=99).save() nescafe_gold = Coffee(name="Nescafe 1003", price=11).save() - tesco = Supplier(name="Sainsburys", delivery_cost=3).save() + tesco = Supplier(name="Tesco", delivery_cost=3).save() nescafe.suppliers.connect(tesco) nescafe_gold.suppliers.connect(tesco) nescafe.species.connect(arabica) @@ -773,15 +780,12 @@ def test_annotate_and_collect(): @mark_sync_test def test_resolve_subgraph(): - # Clean DB before we start anything... - db.cypher_query("MATCH (n) DETACH DELETE n") - arabica = Species(name="Arabica").save() robusta = Species(name="Robusta").save() nescafe = Coffee(name="Nescafe", price=99).save() nescafe_gold = Coffee(name="Nescafe Gold", price=11).save() - tesco = Supplier(name="Sainsburys", delivery_cost=3).save() + tesco = Supplier(name="Tesco", delivery_cost=3).save() nescafe.suppliers.connect(tesco) nescafe_gold.suppliers.connect(tesco) nescafe.species.connect(arabica) @@ -823,14 +827,11 @@ def test_resolve_subgraph(): @mark_sync_test def test_resolve_subgraph_optional(): - # Clean DB before we start anything... - db.cypher_query("MATCH (n) DETACH DELETE n") - arabica = Species(name="Arabica").save() nescafe = Coffee(name="Nescafe", price=99).save() nescafe_gold = Coffee(name="Nescafe Gold", price=11).save() - tesco = Supplier(name="Sainsburys", delivery_cost=3).save() + tesco = Supplier(name="Tesco", delivery_cost=3).save() nescafe.suppliers.connect(tesco) nescafe_gold.suppliers.connect(tesco) nescafe.species.connect(arabica) @@ -850,9 +851,6 @@ def test_resolve_subgraph_optional(): @mark_sync_test def test_subquery(): - # Clean DB before we start anything... - db.cypher_query("MATCH (n) DETACH DELETE n") - arabica = Species(name="Arabica").save() nescafe = Coffee(name="Nescafe", price=99).save() supplier1 = Supplier(name="Supplier 1", delivery_cost=3).save() @@ -889,9 +887,6 @@ def test_subquery(): @mark_sync_test def test_intermediate_transform(): - # Clean DB before we start anything... - db.cypher_query("MATCH (n) DETACH DELETE n") - arabica = Species(name="Arabica").save() nescafe = Coffee(name="Nescafe", price=99).save() supplier1 = Supplier(name="Supplier 1", delivery_cost=3).save() @@ -940,6 +935,95 @@ def test_intermediate_transform(): ) +@mark_sync_test +def test_mix_functions(): + # Test with a mix of all advanced querying functions + + eiffel_tower = Building(name="Eiffel Tower").save() + empire_state_building = Building(name="Empire State Building").save() + miranda = Student(name="Miranda").save() + miranda.lives_in.connect(empire_state_building) + jean_pierre = Student(name="Jean-Pierre").save() + jean_pierre.lives_in.connect(eiffel_tower) + mireille = Student(name="Mireille").save() + mimoun_jr = Student(name="Mimoun Jr").save() + mimoun = Student(name="Mimoun").save() + mireille.lives_in.connect(eiffel_tower) + mimoun_jr.lives_in.connect(eiffel_tower) + mimoun.lives_in.connect(eiffel_tower) + mimoun.parents.connect(mireille) + mimoun.children.connect(mimoun_jr) + math = Course(name="Math").save() + dessin = Course(name="Dessin").save() + mimoun.courses.connect( + math, + { + "level": "1.2", + "start_date": datetime(2020, 6, 2), + "end_date": datetime(2020, 12, 31), + }, + ) + mimoun.courses.connect( + math, + { + "level": "1.1", + "start_date": datetime(2020, 1, 1), + "end_date": datetime(2020, 6, 1), + }, + ) + mimoun_jr.courses.connect( + math, + { + "level": "1.1", + "start_date": datetime(2020, 1, 1), + "end_date": datetime(2020, 6, 1), + }, + ) + + mimoun_jr.preferred_course.connect(dessin) + + full_nodeset = ( + Student.nodes.filter(name__istartswith="m", lives_in__name="Eiffel Tower") + .order_by("name") + .fetch_relations( + "parents", + Optional("children__preferred_course"), + ) + .subquery( + Student.nodes.fetch_relations("courses") + .intermediate_transform( + {"rel": RelationNameResolver("courses")}, + ordering=[ + RawCypher("toInteger(split(rel.level, '.')[0])"), + RawCypher("toInteger(split(rel.level, '.')[1])"), + "rel.end_date", + "rel.start_date", + ], + ) + .annotate( + latest_course=Last(Collect("rel")), + ), + ["latest_course"], + ) + ) + + subgraph = full_nodeset.annotate( + children=Collect(NodeNameResolver("children"), distinct=True), + children_preferred_course=Collect( + NodeNameResolver("children__preferred_course"), distinct=True + ), + ).resolve_subgraph() + + assert len(subgraph) == 2 + assert subgraph[0] == mimoun + assert subgraph[1] == mimoun_jr + mimoun_returned_rels = subgraph[0]._relations + assert mimoun_returned_rels["children"] == mimoun_jr + assert mimoun_returned_rels["children"]._relations["preferred_course"] == dessin + assert mimoun_returned_rels["parents"] == mireille + assert mimoun_returned_rels["latest_course_relationship"].level == "1.2" + + @mark_sync_test def test_issue_795(): jim = PersonX(name="Jim", age=3).save() # Create @@ -983,9 +1067,6 @@ def test_in_filter_with_array_property(): def test_async_iterator(): n = 10 if Util.is_async_code: - for c in Coffee.nodes: - c.delete() - for i in range(n): Coffee(name=f"xxx_{i}", price=i).save() diff --git a/test/sync_/test_paths.py b/test/sync_/test_paths.py index 8e0ccf90..1a6429bf 100644 --- a/test/sync_/test_paths.py +++ b/test/sync_/test_paths.py @@ -85,12 +85,3 @@ def test_path_instantiation(): assert type(path_rels[0]) is PersonLivesInCity assert type(path_rels[1]) is StructuredRel - - c1.delete() - c2.delete() - ct1.delete() - ct2.delete() - p1.delete() - p2.delete() - p3.delete() - p4.delete() diff --git a/test/sync_/test_properties.py b/test/sync_/test_properties.py index 1afe52a2..53ae0002 100644 --- a/test/sync_/test_properties.py +++ b/test/sync_/test_properties.py @@ -408,10 +408,6 @@ def test_independent_property_name(): rel = x.knows.relationship(x) assert rel.known_for == r.known_for - # -- cleanup -- - - x.delete() - @mark_sync_test def test_independent_property_name_for_semi_structured(): @@ -445,8 +441,6 @@ class TestDBNamePropertySemiStructuredNode(SemiStructuredNode): # assert not hasattr(from_get, "title") assert from_get.extra == "data" - semi.delete() - @mark_sync_test def test_independent_property_name_get_or_create(): @@ -465,9 +459,6 @@ class TestNode(StructuredNode): assert node_properties["name"] == "jim" assert "name_" not in node_properties - # delete node afterwards - x.delete() - @mark.parametrize("normalized_class", (NormalizedProperty,)) def test_normalized_property(normalized_class): @@ -638,9 +629,6 @@ class ConstrainedTestNode(StructuredNode): node_properties = get_graph_entity_properties(results[0][0]) assert node_properties["unique_required_property"] == "unique and required" - # delete node afterwards - x.delete() - @mark_sync_test def test_unique_index_prop_enforced(): @@ -665,11 +653,6 @@ class UniqueNullableNameNode(StructuredNode): results, _ = db.cypher_query("MATCH (n:UniqueNullableNameNode) RETURN n") assert len(results) == 3 - # Delete nodes afterwards - x.delete() - y.delete() - z.delete() - def test_alias_property(): class AliasedClass(StructuredNode): diff --git a/test/sync_/test_transactions.py b/test/sync_/test_transactions.py index 834b538e..71ce479f 100644 --- a/test/sync_/test_transactions.py +++ b/test/sync_/test_transactions.py @@ -14,9 +14,6 @@ class APerson(StructuredNode): @mark_sync_test def test_rollback_and_commit_transaction(): - for p in APerson.nodes: - p.delete() - APerson(name="Roger").save() db.begin() @@ -41,8 +38,6 @@ def in_a_tx(*names): @mark_sync_test def test_transaction_decorator(): db.install_labels(APerson) - for p in APerson.nodes: - p.delete() # should work in_a_tx("Roger") @@ -68,9 +63,6 @@ def test_transaction_as_a_context(): @mark_sync_test def test_query_inside_transaction(): - for p in APerson.nodes: - p.delete() - with db.transaction: APerson(name="Alice").save() APerson(name="Bob").save() @@ -119,9 +111,6 @@ def in_a_tx_with_bookmark(*names): @mark_sync_test def test_bookmark_transaction_decorator(): - for p in APerson.nodes: - p.delete() - # should work result, bookmarks = in_a_tx_with_bookmark("Ruth", bookmarks=None) assert result is None @@ -181,9 +170,6 @@ def test_bookmark_passed_in_to_context(spy_on_db_begin): @mark_sync_test def test_query_inside_bookmark_transaction(): - for p in APerson.nodes: - p.delete() - with db.transaction as transaction: APerson(name="Alice").save() APerson(name="Bob").save()