emehrkay / Pypher

Python Cypher Querybuilder
MIT License
170 stars 29 forks source link
complex-queries cypher cypher-query python query-builder

Pypher -- Cypher, but in Python

Pypher is a tiny library that focuses on building Cypher queries by constructing pure Python objects.

Binder

Setup

python setup.py install
pip install python_cypher

Running Tests

python setup.py test

Or if the package is already installed

python -m unittest pypher.test.builder

Usage

Pypher is pretty simple and has a small interface. Pypher tries to replicate building a Cypher query by utilizing all of Python's magic methods behind the scenes.

Let's say you wanted to write this Cypher query:

MATCH (mark:Person)
WHERE mark.name = "Mark"
RETURN mark;

Your Pypher would look like this:

from pypher import Pypher

q = Pypher()
q.Match.node('mark', labels='Person').WHERE.mark.property('name') == 'Mark'
q.RETURN.mark

That isn't a one-to-one match, but it is close. More importantly, easy to read, understand, and compose complex queries without string concatenation.

Creating an actual Cypher string from a Pypher query is simple

cypher = str(q) # MATCH (mark:`Person`) WHERE mark.`name` = NEO_9326c_1 RETURN mark
params = q.bound_params # {'NEO_9326c_1': 'Mark'}

Note: Pypher doesn't create the Cypher string until your Pypher instance is converted into a string via str(p) or print(p) etc., at the same time all of the bound parameters are collected through the many possible sub-instances of Pypher objects that may be in the chain.

Structure

Pypher is a very simple query builder for Cypher. It works by creating a simple linked list of objects and running __str__ against the list when it is time to render the Cypher. Along the way it stores bound params, allows for complex Cypher queries with deep Pypher nestings, and even direct string inclusion if the abstraction gets too messy.

Pypher Objects

Pypher

Pypher is the root object that all other objects sub-class and it makes everything work. Every operation taken on it (attribute access or assignments or comparisons) will result in link being added to list.

Quoting: by default Pypher will quote labels, properties, and map_keys with backticks . This behavior can be overwritten by setting the QUOTE value in the builder module.import pyper; pyper.builder.QUOTES['propery'] = '"'` this sets the quote marksf for properties to be a double quote instead of a backtick

Useful Methods and Properties

Operators

Since Pypher is an object whose sole job is to compose a linked list via a fluid interface, adding common operators to the object is tricky. Here are some rules:

from pypher import Pypher, __

p = Pypher()
p.WHERE.n.name == __.s.__name__

str(p) # WHERE n.`name` = s.`name`

# custom operator
x = Pypher()
x.WHERE.name.operator('**', 'mark') # mark will be a bound param
str(x) # WHERE n.name ** NEO_az23p_0 
Pypher Operator Resulting Cypher Supports Referece Assignment
== = -
!= <> -
+ + yes
+= += -
- - yes
-= -= -
* * yes
*= *= -
/ / yes
/= /= -
% % yes
%= %= -
& & yes
\| \| yes
^ ^ yes
^= ^= -
> > -
>= >= -
< < -
<= <= -

Operator Methods

Some methods resolve to Operator instances. These are called on the Pypher instance with parenthesis.

Pypher Operator Resulting Cypher
.AND(other) AND other
.OR(other) OR other
.ALIAS(other) AS other
.AS(other) AS other
.rexp(other) =~ $other_bound_param
.BAND(right, left) apoc.bitwise.op(right, "&", left)
.BOR(right, left) apoc.bitwise.op(right, "\|", left)
.BXOR(right, left) apoc.bitwise.op(right, "^", left)
.BNOT(right, left) apoc.bitwise.op(right, "~", left)
.BLSHIFT(right, left) apoc.bitwise.op(right, ">>", left)
.BRSHIFT(right, left) apoc.bitwise.op(right, "<<", left)
.BULSHIFT(right, left) apoc.bitwise.op(right, ">>>", left)

__ (double underscore)

____ The double underscore object is just an instance of Anon. It is basically a factory class that creates instances of Pypher when attributes are accessed against it.

from pypher import __, Pypher

p = Pypher()

p.MATCH.node('mark', labels='Person').rel(labels='knows').node('mikey', labels=['Cat', 'Animal'])
p.RETURN(__.mark, __.mikey) 

str(p) # MATCH (mark:`Person`)-[:`knows`]-(mikey:`Cat`:`Animal`) RETURN mark, mikey

# OR

p = Pypher()

p.MATCH.node('mark').SET(__.mark.property('name') == 'Mark!!')

print(str(p)) # MATCH (mark) SET mark.`name` = $NEO_2548a_0
print(dict(p.bound_params)) # {'NEO_2548a_0': 'Mark!!'}

The __ is just an instance of the Anon object. You can change what you want your factory name to be, or create an instance of Anon and assign it to another variable as you see fit.

Param

Param objects are simple containers that store a name and a value.

from pypher import Param, Pypher, __

p = Pypher()
name = Param(name='namedParam', value='Mark')
p.SET(__.m.__name__ == name)

str(p) # SET m.`name` = namedParam
print(p.bound_params) # {'namedParam': 'Mark'}

# reusing the same reference per value
param = p.bind_param('some value', 'key')
param2 = p.bind_param('some_value')

param.name == param2.name # True

# reusing the same reference when the value is the key
param = p.bind_param('some value', 'some key')
param2 = p.bind_param('some key')

param.name == param2.name # True
param.value == params2.value # True

Statement

Statement objects are simple, they are things like MATCH or CREATE or RETURN.

Pypher Object Resulting Cypher Aliases
Match MATCH
Create CREATE
Merge MERGE
Delete DELETE
Remove REMOVE
Drop DROP
Where WHERE
Distinct DISTINCT
OrderBy ORDER BY
Set SET
Skip SKIP
Limit LIMIT
Return RETURN
Unwind UNWIND
ASSERT ASSERT
Detach DETACH
DetachDelete DETACH DELETE
Foreach FOREACH
Load LOAD
CSV CSV
FROM FROM
Headers HEADERS
LoadCsvFrom LOAD CSV FROM
LoadCSVWithHeadersFrom LOAD CSV WITH HEADERS FROM
WITH WITH
UsingPeriodIcCommit USING PERIODIC COMMIT
Periodic PERIODIC
Commit COMMIT
FieldTerminator FIELDTERMINATOR
Optional OPTIONAL
OptionalMatch OPTIONAL MATCH
Desc DESC
When WHEN
ELSE ELSE
Case CASE
End END
OnCreate ON CREATE
OnCreateSet ON CREATE SET
OnMatchSet ON MATCH SET
CreateIndexOn CREATE INDEX ON
UsingIndex USING INDEX
DropIndexOn DROP INDEX ON
CreateConstraintOn CREATE CONSTRAINT ON
DropConstraintOn DROP CONSTRAINT ON
In IN
Map {}
MapProjection var {} map_projection projection
NOT NOT
IS IS
OR OR
NULL NULL
IS_NULL IS NULL
IS NOT NULL IS NOT NULL

Python keywords will be in all CAPS

from pypher import create_statement, Pypher

create_statement('MyStatementName', {'name': 'MY STATEMENT IN CYPHER'})

p = Pypher()

p.MyStatementName.is.cool

str(p) # MY STATEMENT IN CYPHER IS COOL

The name definition is optional. If omitted the resulting Cypher will be the class name in call caps

Another way is to sub-class the Statement class

from pypher import Pypher, Statement

class MyStatement(Statement):
    _CAPITALIZE = True # will make the resulting name all caps. Defaults to True
    _ADD_PRECEEDING_WS = True # add whitespace before the resulting Cypher string. Defaults to True
    _CLEAR_PRECEEDING_WS = True # add whitespace after the resulting Cypher string. Defaults to False
    _ALIASES = ['myst',] # aliases for your custom statement. Will throw an exception if it is already defined
    name = 'my statement name' # the string that will be printed in the resulting Cypher. If this isn't defined, the class name will be used

Func

Func objects resolve to functions (things that have parenthesis)

Pypher Object Resulting Cypher
size size
reverse reverse
head head
tail tail
last last
extract extract
filter filter
reduce reduce
Type type
startNode startNode
endNode endNode
count count
ID id
collect collect
sum sum
percentileDisc percentileDisc
stDev stDev
coalesce coalesce
timestamp timestamp
toInteger toInteger
toFloat toFloat
toBoolean toBoolean
keys keys
properties properties
length length
nodes nodes
relationships relationships
point point
distance distance
abs abs
rand rand
ROUND round
CEIL ceil
Floor floor
sqrt sqrt
sign sign
sin sin
cos cos
tan tan
cot cot
asin asin
acos acos
atan atan
atanZ atanZ
haversin haversin
degrees degrees
radians radians
pi pi
log10 log10
log log
exp exp
E e
toString toString
replace replace
substring substring
left left
right right
trim trim
ltrim ltrim
toUpper toUpper
toLower toLower
SPLIT split
exists exists
MAX max

Python keywords will be in all CAPS

from pypher import create_function, Pypher

create_function('myFunction', {'name': 'mfun'})

p = Pypher()

p.myFunction(1, 2, 3)

str(p) # myFunction(1, 2, 3) note that the arguments will be bound and not "1, 2, 3"

The name definition is optional. If omitted the resulting Cypher will be the exact name of the function

Another way is to sub-class the Func or FuncRaw class.

FuncRaw will not bind its arguments.

from pypher import Pypher, Func, FuncRaw

class MyCustomFunction(Func):
    _CAPATILIZE = True # will make the resulting name all caps. Defaults to False
    _ADD_PRECEEDING_WS = True # add whitespace before the resulting Cypher string. Defaults to True
    _CLEAR_PRECEEDING_WS = True # add whitespace after the resulting Cypher string. Defaults to False
    _ALIASES = ['myst',] # aliases for your custom function. Will throw an exception if it is already defined
    name = 'myCustomFunction' # the string that will be printed in the resulting Cypher. If this isn't defined, the class name will be used

Conditionals

Conditional objects allow groupings of values surrounded by parenthesis and separated by a comma or other value.

Pypher Object Resulting Cypher Aliases
Conditional (val, val2, valN)
ConditionalAND (val AND val2 AND valN) CAND, COND_AND
ConditionalOR (val OR val2 OR valN) COR, COND_OR

Entity

Entities are Node or Relationship objects. They both sub-class the Entity class and share the same attributes.

_Node__ This represents an actual node in the ascii format.

_Relationship__ This represents an relationship node in the ascii format.

Property objects simply allow for adding .property to the resulting Cypher query.

Label

Label objects simply add a label to the preceding link.

Partial

Partial objects allows for encapsulation of complex Pypher chains. These objects will allow for preset definitions to be added to the current Pypher instance.

Here is an example of the built in Case Partial that provides a CASE $case [WHEN $when THEN $then,...] [ELSE $else] END addition:

class Case(Partial):

    def __init__(self, case):
        super(Case, self).__init__()

        self._case = case
        self._whens = []
        self._else = None

    def WHEN(self, when, then):
        self._whens.append((when, then))

        return self

    def ELSE(self, else_case):
        self._else = else_case

        return self

    def build(self):
        self.pypher.CASE(self._case)

        for w in self._whens:
            self.pypher.WHEN(w[0]).THEN(w[1])

        if self._else:
            self.pypher.ELSE(self._else)

        self.pypher.END

#usage is simple
p = Pypher()

# build the partial according to its interface
case = Case(__.n.__eyes__)
case.WHEN('"blue"', 1)
case.WHEN('"brown"', 2)
case.ELSE(3)

# add it to the Pypher instance
p.apply_partial(case)

str(p) # CASE n.eyes WHEN "blue" THEN 1 WHEN "brown" THEN 2 ELSE 3 END

As seen in this example, if you want your resulting Cypher to have actual quotes, you must nest quotes when passing in the arguments to the Statement objects

Maps

Cypher allows for Java-style maps to be returned in some complex queries, Pypher provides two classes to assist with map creation: Map and MapProjection

p = Pypher()
p.RETURN.map('one', 'two', three='three')
print(str(p)) # RETURN {one, two, `three`: $three213bd_0}
print(dict(p.bound_params)) # {'three213bd_0': 'three'}

p.reset()
p.RETURN.map_projection('user', '.name', '.age')
print(str(p)) # 'RETURN user {.name, .age}'

Code Examples

This section will simply cover how to write Pypher that will convert to both common and complex Cypher queries.

A Simple Match with WHERE

MATCH (n:Person)-[:KNOWS]->(m:Person)
WHERE n.name = 'Alice'
p.MATCH.node('n', 'Person').rel_out(labels='KNOWS').node('m', 'PERSON').WHERE.n.__name__ == 'Alice'

A Simple Match with IN

MATCH (n:Person)-[:KNOWS]->(m:Person)
WHERE n.name IN ['Alice', 'Bob']
names = ['Alice', 'Bob']
p.MATCH.node('n', 'Person').rel_out(labels='KNOWS').node('m', 'PERSON').WHERE.n.__name__.In(*names)

Create A Node

CREATE (user:User {Name: 'Jim'})
p.CREATE.node('user', 'User', Name='Jim')
MERGE (user:User { Id: 456 })
ON CREATE user
SET user.Name = 'Jim'
p.MERGE.node('user', 'User', Id=456).ON.CREATE.user.SET(__.user.__Name__ == 'Jim')

Create a variable length relationship

MATCH (martin { name: 'Charlie Sheen' })-[:ACTED_IN*1..3]-(movie:Movie)
RETURN movie.title
p.Match.node('martin', name='Charlie Sheen').rel(labels='ACTED_IN', min_hops=1, max_hops=3).node('movie', 'Movie')
p.Return(__.movie.__title__)

Create a fixed length relationship

MATCH (martin { name: 'Charlie Sheen' })-[:ACTED_IN*2]-(movie:Movie)
RETURN movie.title
p.Match.node('martin', name='Charlie Sheen').rel(labels='ACTED_IN', hops=2).node('movie', 'Movie')
p.Return(__.movie.__title__)

Tester

Included is a very bare-bones CLI app that will allow you to test your Pypher scripts. After installing Pypher, you can run the script simply by calling python tester.py. Once loaded you are presented with a screen that will allow you to write Pypher code and it will generate the Cypher and bound params. This is a quick way to check if your Pypher is producing the desired Cypher for your project.

Example tester.py usage