├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── README.rst ├── dev-requirements.txt ├── neoalchemy ├── __init__.py ├── cypher │ ├── __init__.py │ ├── operations.py │ └── verbs.py ├── exceptions.py ├── graph.py ├── ogm │ ├── __init__.py │ ├── base.py │ └── relations.py ├── primitives.py ├── shared │ ├── __init__.py │ └── objects.py └── validators.py ├── pytest.ini ├── requirements.txt ├── setup.cfg ├── setup.py ├── source ├── OGM-classes.rst ├── apis.rst ├── conf.py ├── index.rst ├── intro.rst ├── query-builder.rst ├── querybuilder-classes.rst ├── roadmap.rst ├── schema-OGM.rst └── schema-classes.rst └── tests ├── MockProject ├── __init__.py ├── addresses.py ├── customers.py ├── graph.py └── orders.py ├── cypher ├── test_create.py └── test_match.py ├── graph ├── test_connection.py └── test_schema.py ├── ogm ├── test_nodes.py └── test_validators.py └── test_set_once.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | pyvenv.cfg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # IPython Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # virtualenv 83 | venv/ 84 | ENV/ 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | 89 | # Rope project settings 90 | .ropeproject 91 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.3" 5 | - "3.4" 6 | - "3.5" 7 | - "3.6" 8 | # command to install dependencies 9 | install: 10 | - pip install -r dev-requirements.txt 11 | - pip install -e . 12 | # command to run tests 13 | script: py.test --cov=neoalchemy 14 | after_success: codecov 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 16 | 17 | .PHONY: help 18 | help: 19 | @echo "Please use \`make ' where is one of" 20 | @echo " html to make standalone HTML files" 21 | @echo " dirhtml to make HTML files named index.html in directories" 22 | @echo " singlehtml to make a single large HTML file" 23 | @echo " pickle to make pickle files" 24 | @echo " json to make JSON files" 25 | @echo " htmlhelp to make HTML files and a HTML help project" 26 | @echo " qthelp to make HTML files and a qthelp project" 27 | @echo " applehelp to make an Apple Help Book" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " epub3 to make an epub3" 31 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 32 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 33 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 34 | @echo " text to make text files" 35 | @echo " man to make manual pages" 36 | @echo " texinfo to make Texinfo files" 37 | @echo " info to make Texinfo files and run them through makeinfo" 38 | @echo " gettext to make PO message catalogs" 39 | @echo " changes to make an overview of all changed/added/deprecated items" 40 | @echo " xml to make Docutils-native XML files" 41 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 42 | @echo " linkcheck to check all external links for integrity" 43 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 44 | @echo " coverage to run coverage check of the documentation (if enabled)" 45 | @echo " dummy to check syntax errors of document sources" 46 | 47 | .PHONY: clean 48 | clean: 49 | rm -rf $(BUILDDIR)/* 50 | 51 | .PHONY: html 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | .PHONY: dirhtml 58 | dirhtml: 59 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 60 | @echo 61 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 62 | 63 | .PHONY: singlehtml 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | .PHONY: pickle 70 | pickle: 71 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 72 | @echo 73 | @echo "Build finished; now you can process the pickle files." 74 | 75 | .PHONY: json 76 | json: 77 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 78 | @echo 79 | @echo "Build finished; now you can process the JSON files." 80 | 81 | .PHONY: htmlhelp 82 | htmlhelp: 83 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 84 | @echo 85 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 86 | ".hhp project file in $(BUILDDIR)/htmlhelp." 87 | 88 | .PHONY: qthelp 89 | qthelp: 90 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 91 | @echo 92 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 93 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 94 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/NeoAlchemy.qhcp" 95 | @echo "To view the help file:" 96 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/NeoAlchemy.qhc" 97 | 98 | .PHONY: applehelp 99 | applehelp: 100 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 101 | @echo 102 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 103 | @echo "N.B. You won't be able to view it unless you put it in" \ 104 | "~/Library/Documentation/Help or install it in your application" \ 105 | "bundle." 106 | 107 | .PHONY: devhelp 108 | devhelp: 109 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 110 | @echo 111 | @echo "Build finished." 112 | @echo "To view the help file:" 113 | @echo "# mkdir -p $$HOME/.local/share/devhelp/NeoAlchemy" 114 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/NeoAlchemy" 115 | @echo "# devhelp" 116 | 117 | .PHONY: epub 118 | epub: 119 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 120 | @echo 121 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 122 | 123 | .PHONY: epub3 124 | epub3: 125 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 126 | @echo 127 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." 128 | 129 | .PHONY: latex 130 | latex: 131 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 132 | @echo 133 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 134 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 135 | "(use \`make latexpdf' here to do that automatically)." 136 | 137 | .PHONY: latexpdf 138 | latexpdf: 139 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 140 | @echo "Running LaTeX files through pdflatex..." 141 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 142 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 143 | 144 | .PHONY: latexpdfja 145 | latexpdfja: 146 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 147 | @echo "Running LaTeX files through platex and dvipdfmx..." 148 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 149 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 150 | 151 | .PHONY: text 152 | text: 153 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 154 | @echo 155 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 156 | 157 | .PHONY: man 158 | man: 159 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 160 | @echo 161 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 162 | 163 | .PHONY: texinfo 164 | texinfo: 165 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 166 | @echo 167 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 168 | @echo "Run \`make' in that directory to run these through makeinfo" \ 169 | "(use \`make info' here to do that automatically)." 170 | 171 | .PHONY: info 172 | info: 173 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 174 | @echo "Running Texinfo files through makeinfo..." 175 | make -C $(BUILDDIR)/texinfo info 176 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 177 | 178 | .PHONY: gettext 179 | gettext: 180 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 181 | @echo 182 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 183 | 184 | .PHONY: changes 185 | changes: 186 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 187 | @echo 188 | @echo "The overview file is in $(BUILDDIR)/changes." 189 | 190 | .PHONY: linkcheck 191 | linkcheck: 192 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 193 | @echo 194 | @echo "Link check complete; look for any errors in the above output " \ 195 | "or in $(BUILDDIR)/linkcheck/output.txt." 196 | 197 | .PHONY: doctest 198 | doctest: 199 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 200 | @echo "Testing of doctests in the sources finished, look at the " \ 201 | "results in $(BUILDDIR)/doctest/output.txt." 202 | 203 | .PHONY: coverage 204 | coverage: 205 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 206 | @echo "Testing of coverage in the sources finished, look at the " \ 207 | "results in $(BUILDDIR)/coverage/python.txt." 208 | 209 | .PHONY: xml 210 | xml: 211 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 212 | @echo 213 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 214 | 215 | .PHONY: pseudoxml 216 | pseudoxml: 217 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 218 | @echo 219 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 220 | 221 | .PHONY: dummy 222 | dummy: 223 | $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy 224 | @echo 225 | @echo "Build finished. Dummy builder generates no files." 226 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | NeoAlchemy 2 | ========== 3 | 4 | [![Docs for 0.8.0b](https://readthedocs.org/projects/neoalchemy/badge/?version=0.8.0b)](http://neoalchemy.readthedocs.io/en/0.8.0b/?badge=0.8.0b) 5 | [![Latest Build Status](https://travis-ci.org/TwoBitAlchemist/NeoAlchemy.svg?branch=0.8.0b)](https://travis-ci.org/TwoBitAlchemist/NeoAlchemy) 6 | [![Codecov.io Report](https://codecov.io/gh/TwoBitAlchemist/NeoAlchemy/branch/0.8.0b/graph/badge.svg)](https://codecov.io/gh/TwoBitAlchemist/NeoAlchemy) 7 | 8 | NeoAlchemy is a SqlAlchemy-like tool for working with the Neo4J graph database 9 | in Python. It is intended to be very easy to use, and intuitively familiar to 10 | anyone who has used SqlAlchemy and/or the Cypher Query Language. 11 | 12 | NeoAlchemy is built on top of the Neo4J Bolt driver and only supports Neo4J 13 | 3.0+ connected over the Bolt protocol. It supports Python 2.7 and 3.3+. 14 | 15 | Getting Started 16 | --------------- 17 | 18 | NeoAlchemy is available on [PyPI][1], so it can be installed normally using 19 | `pip install neoalchemy`. NeoAlchemy is built on top of the [official Neo4J 20 | Python driver][2]. If you install from PyPI, this will automatically be 21 | installed alongside it. You can also install the dependencies using `pip 22 | install -r requirements.txt`. 23 | 24 | [Questions, support requests, comments][3], and [contributions][4] should be 25 | directed to GitHub accordingly. 26 | 27 | Low-Level QueryBuilder API 28 | -------------------------- 29 | 30 | ``` python 31 | import uuid 32 | 33 | from neoalchemy import Create, Node, Property, Graph 34 | from neoalchemy.validators import UUID 35 | 36 | graph = Graph() 37 | 38 | person = Node('Person', # primary label 39 | uuid=Property(unique=True, type=UUID, default=uuid.uuid4), 40 | real_name=Property(indexed=True), 41 | screen_name=Property(indexed=True, type=str.lower), 42 | age=Property(type=int) 43 | ) 44 | 45 | # Emit schema-generating DDL 46 | graph.schema.create(person) 47 | 48 | person.real_name = 'Alison' 49 | person.screen_name = 'Ali42' 50 | person.age = 29 51 | create = Create(person) 52 | 53 | graph.query(create, **create.params) 54 | ``` 55 | 56 | [Learn more about the QueryBuilder API][5]. 57 | 58 | 59 | High-Level Schema OGM 60 | --------------------- 61 | 62 | ``` python 63 | import uuid 64 | 65 | from neoalchemy import OGMBase, Property, Graph 66 | from neoalchemy.validators import UUID 67 | 68 | class Person(OGMBase): 69 | graph = Graph() 70 | 71 | uuid = Property(unique=True, type=UUID, default=uuid.uuid4) 72 | real_name = Property(indexed=True) 73 | screen_name = Property(indexed=True, type=str.lower) 74 | age = Property(type=int) 75 | 76 | # Cypher schema generation emitted automatically 77 | # No user action required 78 | 79 | Person(real_name='Alison', screen_name='Ali42', age=29).create() 80 | ``` 81 | 82 | [Learn more about the Schema OGM][6]. 83 | 84 | [1]: https://pypi.python.org/pypi/neoalchemy 85 | [2]: https://neo4j.com/developer/python/ 86 | [3]: https://github.com/TwoBitAlchemist/NeoAlchemy/issues/new 87 | [4]: https://github.com/TwoBitAlchemist/NeoAlchemy 88 | [5]: http://neoalchemy.readthedocs.io/en/0.8.0b/query-builder.html 89 | [6]: http://neoalchemy.readthedocs.io/en/0.8.0b/schema-ORM.html 90 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ********** 2 | NeoAlchemy 3 | ********** 4 | 5 | .. image:: https://readthedocs.org/projects/neoalchemy/badge/?version=0.8.0b 6 | :target: http://neoalchemy.readthedocs.io/en/latest/?badge=0.8.0b 7 | :alt: Docs for 0.8.0b 8 | .. image:: https://travis-ci.org/TwoBitAlchemist/NeoAlchemy.svg?branch=v0.8.0b 9 | :target: https://travis-ci.org/TwoBitAlchemist/NeoAlchemy 10 | 11 | .. image:: https://codecov.io/gh/TwoBitAlchemist/NeoAlchemy/branch/0.8.0b/graph/badge.svg 12 | :target: https://codecov.io/gh/TwoBitAlchemist/NeoAlchemy 13 | 14 | NeoAlchemy is a SqlAlchemy-like tool for working with the Neo4J graph database 15 | in Python. It is intended to be very easy to use, and intuitively familiar to 16 | anyone who has used SqlAlchemy and/or the Cypher Query Language. 17 | 18 | NeoAlchemy is built on top of the Neo4J Bolt driver and only supports Neo4J 19 | 3.0+ connected over the Bolt protocol. It supports Python 2.7 and 3.3+. 20 | 21 | =============== 22 | Getting Started 23 | =============== 24 | 25 | NeoAlchemy is available on `PyPI`_, so it can be installed normally using 26 | `pip install neoalchemy`. NeoAlchemy is built on top of the `official Neo4J 27 | Python driver`_. If you install from PyPI, this will automatically be 28 | installed alongside it. You can also install the dependencies using `pip 29 | install -r requirements.txt`. 30 | 31 | `Questions, support requests, comments`_, and `contributions`_ should be 32 | directed to GitHub accordingly. 33 | 34 | ========================== 35 | Low-Level QueryBuilder API 36 | ========================== 37 | 38 | :: 39 | 40 | import uuid 41 | 42 | from neoalchemy import Create, Node, Property, Graph 43 | from neoalchemy.validators import UUID 44 | 45 | graph = Graph() 46 | 47 | person = Node('Person', # primary label 48 | uuid=Property(unique=True, type=UUID, default=uuid.uuid4), 49 | real_name=Property(indexed=True), 50 | screen_name=Property(indexed=True, type=str.lower), 51 | age=Property(type=int) 52 | ) 53 | 54 | # Emit schema-generating DDL 55 | graph.schema.create(person) 56 | 57 | person.real_name = 'Alison' 58 | person.screen_name = 'Ali42' 59 | person.age = 29 60 | create = Create(person) 61 | 62 | graph.query(create, **create.params) 63 | 64 | `Learn more about the QueryBuilder API`_. 65 | 66 | 67 | ===================== 68 | High-Level Schema OGM 69 | ===================== 70 | 71 | :: 72 | 73 | import uuid 74 | 75 | from neoalchemy import OGMBase, Property, Graph 76 | from neoalchemy.validators import UUID 77 | 78 | class Person(OGMBase): 79 | graph = Graph() 80 | 81 | uuid = Property(unique=True, type=UUID, default=uuid.uuid4) 82 | real_name = Property(indexed=True) 83 | screen_name = Property(indexed=True, type=str.lower) 84 | age = Property(type=int) 85 | 86 | # Cypher schema generation emitted automatically 87 | # No user action required 88 | 89 | Person(real_name='Alison', screen_name='Ali42', age=29).create() 90 | 91 | `Learn more about the Schema OGM`_. 92 | 93 | .. _PyPI: https://pypi.python.org/pypi/neoalchemy 94 | .. _official Neo4J Python driver: https://neo4j.com/developer/python/ 95 | .. _Questions, support requests, comments: https://github.com/TwoBitAlchemist/NeoAlchemy/issues/new 96 | .. _contributions: https://github.com/TwoBitAlchemist/NeoAlchemy 97 | .. _Learn more about the QueryBuilder API: http://neoalchemy.readthedocs.io/en/0.8.0b/query-builder.html 98 | .. _Learn more about the Schema ORM: http://neoalchemy.readthedocs.io/en/0.8.0b/schema-ORM.html 99 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | alabaster==0.7.8 2 | Babel==2.9.1 3 | codecov==2.0.5 4 | coverage==4.1 5 | docopt==0.6.2 6 | docutils==0.12 7 | imagesize==0.7.1 8 | Jinja2==2.11.3 9 | MarkupSafe==0.23 10 | neo4j-driver==1.0.2 11 | py==1.10.0 12 | Pygments==2.7.4 13 | pytest==2.9.2 14 | pytest-cov==2.3.0 15 | python-dateutil==2.5.3 16 | pytz==2016.6 17 | requests==2.20.0 18 | six==1.10.0 19 | snowballstemmer==1.2.1 20 | Sphinx==1.4.5 21 | wheel==0.29.0 22 | -------------------------------------------------------------------------------- /neoalchemy/__init__.py: -------------------------------------------------------------------------------- 1 | from .cypher import Create, Match, Merge 2 | from .graph import Graph 3 | from .ogm import OGMBase, OneToManyRelation, ManyToManyRelation 4 | from .primitives import Node, Relationship 5 | from .shared.objects import Property 6 | -------------------------------------------------------------------------------- /neoalchemy/cypher/__init__.py: -------------------------------------------------------------------------------- 1 | from .verbs import Create, Match, Merge 2 | from .operations import (All, Any, Avg, Collect, Count, Distinct, Exists, 3 | Max, Min, None_, Single, Sum, Unwind) 4 | -------------------------------------------------------------------------------- /neoalchemy/cypher/operations.py: -------------------------------------------------------------------------------- 1 | try: 2 | str = unicode 3 | except NameError: 4 | pass 5 | 6 | 7 | class QueryParams(dict): 8 | def __init__(self, *args, **kw): 9 | self.__extra_params = 0 10 | self.__last_key = None 11 | super(QueryParams, self).__init__(*args, **kw) 12 | 13 | @property 14 | def last_key(self): 15 | return self.__last_key 16 | 17 | def __setitem__(self, key, value): 18 | if self.get(key, object()) == value: 19 | return 20 | if key is None or self.get(str(key)) is not None: 21 | key = 'param%i' % self.__extra_params 22 | self.__extra_params += 1 23 | self.__last_key = str(key) 24 | super(QueryParams, self).__setitem__(self.__last_key, value) 25 | 26 | def update(self, mapping): 27 | for key, value in mapping.items(): 28 | self[key] = value 29 | 30 | 31 | class CypherExpression(object): 32 | def __init__(self): 33 | self.__compiled = False 34 | self.__expr = self.__var = None 35 | self.params = QueryParams() 36 | 37 | def compile(self): 38 | self.__compiled = True 39 | return self 40 | 41 | def replace(self, old, new): 42 | self.__expr = self.__expr.replace(old, new) 43 | 44 | @property 45 | def expr(self): 46 | if self.__compiled: 47 | raise AttributeError("'%s' object has no attribute 'expr'" % 48 | self.__class__.__name__) 49 | return self.__expr 50 | 51 | @expr.setter 52 | def expr(self, value): 53 | if self.__compiled: 54 | raise AttributeError("Can't set attribute") 55 | self.__expr = value 56 | 57 | @property 58 | def var(self): 59 | if not self.__compiled: self.compile() 60 | return self.__var 61 | 62 | @var.setter 63 | def var(self, value): 64 | if self.__compiled: 65 | raise AttributeError("Can't set attribute") 66 | self.__var = value 67 | 68 | def __str__(self): 69 | if not self.__compiled: self.compile() 70 | return self.__expr 71 | 72 | def __bool__(self): 73 | return False 74 | 75 | 76 | class CypherOperatorInterface(object): 77 | # Mathematical Operators 78 | def __add__(self, x): # self + x 79 | return ComparisonExpression(self, x, '+') 80 | 81 | def __radd__(self, x): # x + self 82 | return self.__add__(x) 83 | 84 | def __sub__(self, x): # self - x 85 | return ComparisonExpression(self, x, '-') 86 | 87 | def __rsub__(self, x): # x - self 88 | return ComparisonExpression(self, x, '-', reverse=True) 89 | 90 | def __mul__(self, x): # self * x 91 | return ComparisonExpression(self, x, '*') 92 | 93 | def __rmul__(self, x): # x * self 94 | return self.__mul__(x) 95 | 96 | def __div__(self, x): # self / x 97 | return ComparisonExpression(self, x, '/') 98 | 99 | def __rdiv__(self, x): # x / self 100 | return ComparisonExpression(self, x, '/', reverse=True) 101 | 102 | def __truediv__(self, x): # self / x (__future__.division) 103 | return self.__div__(x) 104 | 105 | def __rtruediv__(self, x): # x / self (__future__.division) 106 | return self.__rdiv__(x) 107 | 108 | def __floordiv__(self, x): # self // x 109 | return self.__div__(x) 110 | 111 | def __rfloordiv__(self, x): # x // self 112 | return self.__rdiv__(x) 113 | 114 | def __mod__(self, x): # self % x 115 | return ComparisonExpression(self, x, '%') 116 | 117 | def __rmod__(self, x): # x % self 118 | return ComparisonExpression(self, x, '%', reverse=True) 119 | 120 | def __pow__(self, x): # self ** x 121 | return ComparisonExpression(self, x, '^') 122 | 123 | def __rpow__(self, x): # x ** self 124 | return ComparisonExpression(self, x, '^', reverse=True) 125 | 126 | # Comparison Operators 127 | def __eq__(self, x): # self == x 128 | if x is self: return True 129 | return ComparisonExpression(self, x, '=') 130 | 131 | def __ne__(self, x): # self != x 132 | return ComparisonExpression(self, x, '<>') 133 | 134 | def __lt__(self, x): # self < x 135 | return ComparisonExpression(self, x, '<') 136 | 137 | def __gt__(self, x): # self > x 138 | return ComparisonExpression(self, x, '>') 139 | 140 | def __le__(self, x): # self <= x 141 | return ComparisonExpression(self, x, '<=') 142 | 143 | def __ge__(self, x): # self >= x 144 | return ComparisonExpression(self, x, '>=') 145 | 146 | def like(self, x): 147 | return ComparisonExpression(self, '.*%s.*' % x, '=~') 148 | 149 | 150 | class ComparisonExpression(CypherExpression, CypherOperatorInterface): 151 | def __init__(self, left_operand, right_operand, operator, reverse=False): 152 | super(ComparisonExpression, self).__init__() 153 | self.__left_operand = left_operand 154 | self.__right_operand = right_operand 155 | self.__operator = operator 156 | self.__reverse = reverse 157 | 158 | def compile(self): 159 | if not isinstance(self.__right_operand, CypherOperatorInterface): 160 | if isinstance(self.__left_operand, ComparisonExpression): 161 | var = str(self.__left_operand) 162 | self.params.update(self.__left_operand.params) 163 | else: 164 | var = self.__left_operand.var 165 | 166 | if self.__right_operand is not None: 167 | value = self.__left_operand.type(self.__right_operand) 168 | else: 169 | value = None 170 | 171 | self.params[getattr(self.__left_operand, 'param', None)] = value 172 | self.var = var 173 | expr = (var, self.__operator, '{%s}' % self.params.last_key) 174 | else: 175 | expr = (self.__left_operand.var, self.__operator, 176 | self.__right_operand.var) 177 | 178 | self.expr = ' '.join(reversed(expr) if self.__reverse else expr) 179 | super(ComparisonExpression, self).compile() 180 | return self 181 | 182 | def type(self, other): 183 | return other 184 | 185 | 186 | class Exists(CypherExpression): 187 | def __init__(self, rel, exists=True): 188 | super(Exists, self).__init__() 189 | self.__exists = exists 190 | self.__rel = rel 191 | 192 | def compile(self): 193 | if self.__exists: 194 | self.expr = 'EXISTS(%s)' 195 | else: 196 | self.expr = 'NOT EXISTS(%s)' 197 | self.expr %= self.__rel.pattern() 198 | self.var = self.expr + ' AS %s_exists' % self.__rel.var 199 | super(Exists, self).compile() 200 | return self 201 | 202 | 203 | class CypherFunction(object): 204 | def __init__(self, obj=None): 205 | if obj is None: 206 | self.__obj = None 207 | else: 208 | try: 209 | self.__obj, self.__var = obj.func, obj.inner_var 210 | except AttributeError: 211 | self.__obj = self.__var = obj.var 212 | 213 | @property 214 | def inner_var(self): 215 | return self.__var 216 | 217 | @property 218 | def func(self): 219 | if self.__obj is not None: 220 | return '%s(%s)' % (self.__class__.__name__.upper(), self.__obj) 221 | else: 222 | return '%s(*)' % self.__class__.__name__.upper() 223 | 224 | @property 225 | def var(self): 226 | return '%s AS %s_%s' % (self.func, self.inner_var, 227 | self.__class__.__name__.lower()) 228 | 229 | 230 | class All(CypherFunction): 231 | pass 232 | 233 | 234 | class Any(CypherFunction): 235 | pass 236 | 237 | 238 | class Avg(CypherFunction): 239 | pass 240 | 241 | 242 | class Collect(CypherFunction): 243 | pass 244 | 245 | 246 | class Count(CypherFunction): 247 | pass 248 | 249 | 250 | class Distinct(CypherFunction): 251 | pass 252 | 253 | 254 | class Max(CypherFunction): 255 | pass 256 | 257 | 258 | class Min(CypherFunction): 259 | pass 260 | 261 | 262 | class None_(CypherFunction): 263 | pass 264 | 265 | 266 | class Single(CypherFunction): 267 | pass 268 | 269 | 270 | class Sum(CypherFunction): 271 | pass 272 | 273 | 274 | class Unwind(CypherFunction): 275 | pass 276 | -------------------------------------------------------------------------------- /neoalchemy/cypher/verbs.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import six 4 | 5 | from ..shared.objects import Property 6 | from .operations import CypherExpression, ComparisonExpression, QueryParams 7 | 8 | 9 | class CypherQuery(list): 10 | def __init__(self, graph_obj, use_full_pattern=False): 11 | self.params = QueryParams() 12 | 13 | try: 14 | verb = self.verb 15 | except AttributeError: 16 | verb = self.__class__.__name__.upper() 17 | 18 | pattern = graph_obj.pattern(inline_props=use_full_pattern) 19 | super(CypherQuery, self).__init__(['%s %s' % (verb, pattern)]) 20 | 21 | def delete(self, *args, **kw): 22 | detach = kw.get('detach') 23 | keyword = 'DETACH DELETE ' if detach else 'DELETE ' 24 | self.append(keyword + ', '.join(arg.var for arg in args)) 25 | return self 26 | 27 | def limit(self, limit): 28 | self.append('LIMIT %i' % int(limit)) 29 | return self 30 | 31 | def order_by(self, *args, **kw): 32 | stmt = 'ORDER BY ' + ', '.join(arg.var for arg in args) 33 | if kw.get('desc'): 34 | stmt += ' DESC' 35 | self.append(stmt) 36 | return self 37 | 38 | def remove(self, *args): 39 | self.append('REMOVE ' + ', '.join(arg.var for arg in args)) 40 | return self 41 | 42 | def return_(self, *args): 43 | if args: 44 | self.append('RETURN ' + ', '.join(arg.var for arg in args)) 45 | else: 46 | self.append('RETURN *') 47 | return self 48 | 49 | def set(self, *props): 50 | prop_list = [self._add_expression(prop) for prop in props] 51 | if prop_list: 52 | self.append(' SET ' + ', '.join(prop_list)) 53 | return self 54 | 55 | def skip(self, skip): 56 | self.append('SKIP %i' % int(skip)) 57 | return self 58 | 59 | def where(self, *exprs, **kw): 60 | or_ = kw.pop('or_', False) 61 | stmt_list = [self._add_expression(expr) for expr in exprs] 62 | if stmt_list: 63 | statements = ' AND '.join(stmt_list) 64 | if any(keyword.rjust(9) == self[-1][:9] 65 | for keyword in ('WHERE', 'AND', 'OR')): 66 | keyword = 'AND ' if not or_ else 'OR ' 67 | else: 68 | keyword = 'WHERE ' 69 | self.append(keyword.rjust(10) + statements) 70 | return self 71 | 72 | def with_(self, *args): 73 | self.append('WITH ' + ', '.join(arg.var for arg in args)) 74 | return self 75 | 76 | def _add_expression(self, expr): 77 | if isinstance(expr, Property): 78 | prop = expr 79 | expr = ComparisonExpression(prop, prop.value, '=') 80 | else: 81 | if not isinstance(expr, CypherExpression): 82 | raise ValueError('Must be CypherExpression or Property') 83 | 84 | for key, value in expr.compile().params.items(): 85 | self.params[key] = value 86 | if self.params.last_key.startswith('param'): 87 | expr.replace(key, self.params.last_key) 88 | 89 | return str(expr) 90 | 91 | def __str__(self): 92 | return '\n'.join(map(str, self)) 93 | 94 | def __and__(self, query): 95 | self.extend(query) 96 | self.params.update(query.params) 97 | return self 98 | 99 | def __or__(self, query): 100 | self.append('UNION ALL') 101 | self.extend(query) 102 | self.params.update(query.params) 103 | return self 104 | 105 | def __xor__(self, query): 106 | self.append('UNION') 107 | self.extend(query) 108 | self.params.update(query.params) 109 | return self 110 | 111 | 112 | class Create(CypherQuery): 113 | def __init__(self, graph_obj): 114 | super(Create, self).__init__(graph_obj) 115 | self.set(*graph_obj.values()) 116 | 117 | 118 | class Match(CypherQuery): 119 | def __init__(self, graph_obj, optional=False): 120 | if optional: 121 | self.verb = 'OPTIONAL MATCH' 122 | super(Match, self).__init__(graph_obj) 123 | self.where(*(v for k, v in graph_obj.items() 124 | if k in graph_obj.bound_keys)) 125 | 126 | 127 | class Merge(CypherQuery): 128 | def __init__(self, graph_obj): 129 | super(Merge, self).__init__(graph_obj, use_full_pattern=True) 130 | self.params.update({p.param: p.value for k, p in graph_obj.items() 131 | if k in graph_obj.bound_keys}) 132 | 133 | def on_create(self): 134 | self.append('ON CREATE') 135 | return self 136 | 137 | def on_match(self): 138 | self.append('ON MATCH') 139 | return self 140 | -------------------------------------------------------------------------------- /neoalchemy/exceptions.py: -------------------------------------------------------------------------------- 1 | class DetachedObjectError(TypeError): 2 | def __init__(self, obj, action='write'): 3 | error = "Can't %s %s object when not attached to any graph instance." 4 | cls = obj.__class__.__name__ 5 | super(DetachedObjectError, self).__init__(error % (action, cls)) 6 | 7 | 8 | class ImmutableAttributeError(AttributeError): 9 | def __init__(self, name, obj): 10 | error = "Can't reset immutable attribute '%s' on %s object." 11 | cls = obj.__class__.__name__ 12 | super(ImmutableAttributeError, self).__init__(error % (name, cls)) 13 | 14 | 15 | class UnboundedWriteOperation(RuntimeError): 16 | def __init__(self, obj, error_info=None): 17 | error = "Attempted unbounded write operation on %s object." 18 | error %= obj.__class__.__name__ 19 | if error_info: 20 | error = '%s %s' % (error, error_info) 21 | super(UnboundedWriteOperation, self).__init__(error) 22 | -------------------------------------------------------------------------------- /neoalchemy/graph.py: -------------------------------------------------------------------------------- 1 | """ 2 | A thin wrapper around the Neo4J Bolt driver's GraphDatabase class 3 | providing a convenient auto-connection during initialization. 4 | """ 5 | from collections import deque, namedtuple 6 | import warnings 7 | 8 | from neo4j.v1 import (GraphDatabase, basic_auth, Record, 9 | Node as NeoNode, Relationship as NeoRelationship) 10 | 11 | from .primitives import Node, Relationship 12 | 13 | 14 | class Rehydrator(object): 15 | def __init__(self, statement_result, graph): 16 | self.__result_set = iter(statement_result) 17 | self.__schema = dict(graph.schema.classes) 18 | 19 | def __iter__(self): 20 | return self 21 | 22 | def __next__(self): 23 | record = next(self.__result_set) 24 | values = [] 25 | for value in record.values(): 26 | if isinstance(value, NeoNode): 27 | try: 28 | cls = self.__schema[tuple(value.labels)] 29 | except KeyError: 30 | values.append(Node(*value.labels, **value.properties)) 31 | else: 32 | values.append(cls(**value.properties)) 33 | elif isinstance(value, NeoRelationship): 34 | values.append(Relationship(value.type, **value.properties)) 35 | else: 36 | values.append(value) 37 | return Record(record.keys(), values) 38 | 39 | def next(self): 40 | return self.__next__() 41 | 42 | @property 43 | def one(self): 44 | try: 45 | record = next(self) 46 | except StopIteration: 47 | return None 48 | else: 49 | try: 50 | next(self) 51 | except StopIteration: 52 | pass 53 | else: 54 | warnings.warn('More than one result returned. Data discarded!') 55 | 56 | if len(record.keys()) > 1: 57 | return record 58 | else: 59 | return record[0] 60 | 61 | 62 | class QueryLog(deque): 63 | MAX_SIZE = 100 64 | LogLine = namedtuple('LogLine', ('query', 'params')) 65 | 66 | def __init__(self, *args, **kw): 67 | super(QueryLog, self).__init__(maxlen=self.MAX_SIZE, *args, **kw) 68 | 69 | def __call__(self, query, params): 70 | self.append(self.LogLine(query=query, params=params)) 71 | 72 | 73 | class Query(object): 74 | """Run queries on the Graph""" 75 | def __init__(self, graph): 76 | self.__graph = graph 77 | self.__log = QueryLog() 78 | 79 | def __call__(self, q, **params): 80 | """Syntactic sugar for query.run(str(q), **params)""" 81 | return self.run(str(q), **params) 82 | 83 | @property 84 | def log(self): 85 | return self.__log 86 | 87 | def all(self): 88 | """MATCH (all) RETURN all""" 89 | return self.run('MATCH (all) RETURN all') 90 | 91 | def run(self, query, **params): 92 | """Run an arbitrary Cypher query""" 93 | with self.__graph.session() as session: 94 | self.log(query, params) 95 | return session.run(query, parameters=params) 96 | 97 | 98 | class Reflect(object): 99 | def __init__(self, graph): 100 | self.__graph = graph 101 | 102 | def constraints(self): 103 | """Fetch the current graph constraints""" 104 | constraints = self.__graph.query('CALL db.constraints()') or () 105 | return (r['description'] for r in constraints) 106 | 107 | def indexes(self): 108 | """Fetch the current graph indexes""" 109 | indexes = self.__graph.query('CALL db.indexes()') or () 110 | return (r['description'] for r in indexes) 111 | 112 | def labels(self): 113 | """Fetch the current graph labels""" 114 | labels = self.__graph.query('CALL db.labels()') or () 115 | return (r['label'] for r in labels) 116 | 117 | 118 | class Schema(object): 119 | def __init__(self, graph): 120 | self.__graph = graph 121 | self.__constraints = None 122 | self.__indexes = None 123 | self.__labels = None 124 | self.__reflect = Reflect(graph) 125 | self.__schema = set() 126 | self.__hierarchy = dict() 127 | self.__relations = dict() 128 | 129 | def create(self, obj): 130 | """ 131 | Create the object's schema, if not already present. 132 | """ 133 | node = obj.__node__ 134 | if not node.type or node.type in self.__schema: 135 | return 136 | 137 | def get_or_defer(type_, rel=None): 138 | try: 139 | type_ = self.__hierarchy[type_] 140 | except KeyError: 141 | if type_ not in self.__hierarchy.values(): 142 | if rel is not None: 143 | self.__relations.setdefault(type_, []).append(rel) 144 | return 145 | return type_ 146 | 147 | self.__schema.add(node.type) 148 | if obj.__node__ is not obj: 149 | self.__hierarchy[node.labels] = obj 150 | if node.type: 151 | self.__hierarchy[node.type] = obj 152 | 153 | # add backrefs to deferred types 154 | deferred_types = list(self.__relations.keys()) 155 | for type_ in deferred_types: 156 | type_class = get_or_defer(type_) 157 | if type_class is not None: 158 | for rel in self.__relations[type_]: 159 | rel.create_backref(type_class) 160 | 161 | # add backrefs to new types, or defer them 162 | for attr in obj.__relations__: 163 | rel = getattr(obj, attr) 164 | for type_ in rel.restricted_types: 165 | type_ = get_or_defer(type_, rel) 166 | if type_ is not None: 167 | rel.create_backref(type_) 168 | 169 | schema = self.indexes() + self.constraints() 170 | for index_or_constraint in node.schema: 171 | if index_or_constraint not in schema: 172 | self.__graph.query('CREATE ' + index_or_constraint) 173 | 174 | def drop(self, obj): 175 | """ 176 | Drop the object's schema, if present. 177 | """ 178 | node = obj.__node__ 179 | if node.type in self.__schema: 180 | self.__schema.remove(node.type) 181 | 182 | schema = self.indexes() + self.constraints() 183 | for index_or_constraint in node.schema: 184 | if index_or_constraint in schema: 185 | self.__graph.query('DROP ' + index_or_constraint) 186 | 187 | def drop_all(self): 188 | for constraint in self.constraints(): 189 | self.__graph.query('DROP ' + constraint) 190 | for index in self.__reflect.indexes(): 191 | self.__graph.query('DROP ' + index) 192 | 193 | @property 194 | def classes(self): 195 | return self.__hierarchy.items() 196 | 197 | def constraints(self): 198 | """ 199 | Get current graph constraints lazily. 200 | 201 | On first access, this fetches from the database. Afterwards, call 202 | update() to refresh. 203 | """ 204 | if self.__constraints is None: 205 | self.__constraints = tuple(self.__reflect.constraints()) 206 | return self.__constraints 207 | 208 | def indexes(self): 209 | """ 210 | Get current graph indexes lazily. 211 | 212 | On first access, this fetches from the database. Afterwards, call 213 | update() to refresh. 214 | """ 215 | if self.__indexes is None: 216 | self.__indexes = tuple(self.__reflect.indexes()) 217 | return self.__indexes 218 | 219 | def labels(self): 220 | """ 221 | Get current graph labels lazily. 222 | 223 | On first access, this fetches from the database. Afterwards, call 224 | update() to refresh. 225 | """ 226 | if self.__labels is None: 227 | self.__labels = tuple(self.__reflect.labels()) 228 | return self.__labels 229 | 230 | @property 231 | def ls(self): 232 | """Cypher statements for currently defined schema""" 233 | return '\n'.join(self.indexes() + self.constraints()) 234 | 235 | def update(self): 236 | """Refresh graph constraints, indexes, and labels""" 237 | self.__constraints = tuple(self.__reflect.constraints()) 238 | self.__indexes = tuple(self.__reflect.indexes()) 239 | self.__labels = tuple(self.__reflect.labels()) 240 | return self 241 | 242 | 243 | class Graph(GraphDatabase): 244 | """ 245 | A thin wrapper around the Neo4J Bolt driver's GraphDatabase class 246 | providing a convenient auto-connection during initialization. 247 | """ 248 | def __init__(self, url=None, **kw): 249 | self.connect(url, **kw) 250 | self.__query = Query(self) 251 | self.__schema = Schema(self) 252 | 253 | @property 254 | def query(self): 255 | return self.__query 256 | 257 | @property 258 | def schema(self): 259 | return self.__schema 260 | 261 | def connect(self, url=None, user=None, password=None, **kw): 262 | """ 263 | Parse a Neo4J URL and attempt to connect using Bolt 264 | 265 | Note: If the user and password arguments are provided, they 266 | will only be used in case no auth information is provided as 267 | part of the connection URL. 268 | """ 269 | if url is None: 270 | url = 'bolt://localhost' 271 | if user is None: 272 | user = 'neo4j' 273 | if password is None: 274 | password = 'neo4j' 275 | 276 | try: 277 | protocol, url = url.split('://') 278 | if protocol.lower() != 'bolt': 279 | warnings.warn('Switching protocols. Only Bolt is supported.') 280 | except ValueError: 281 | pass 282 | 283 | try: 284 | credentials, url = url.split('@') 285 | except ValueError: 286 | kw['auth'] = basic_auth(user, password) 287 | else: 288 | kw['auth'] = basic_auth(*credentials.split(':', 1)) 289 | 290 | self.driver = GraphDatabase.driver('bolt://%s' % url, **kw) 291 | 292 | def delete_all(self): 293 | """MATCH (all) DETACH DELETE all""" 294 | with self.session() as session: 295 | session.run('MATCH (all) DETACH DELETE all') 296 | 297 | def session(self): 298 | """Syntactic sugar for graph.driver.session()""" 299 | return self.driver.session() 300 | -------------------------------------------------------------------------------- /neoalchemy/ogm/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import OGMBase 2 | from .relations import OneToManyRelation, ManyToManyRelation 3 | -------------------------------------------------------------------------------- /neoalchemy/ogm/base.py: -------------------------------------------------------------------------------- 1 | import six 2 | 3 | from ..cypher import Create, Match, Merge, Count 4 | from ..exceptions import (DetachedObjectError, ImmutableAttributeError, 5 | UnboundedWriteOperation) 6 | from ..graph import Rehydrator 7 | from ..primitives import Node, Relationship 8 | from .relations import Relation 9 | from ..shared.objects import Property 10 | 11 | 12 | class OGMDescriptor(object): 13 | def __init__(self, name): 14 | self.name = name 15 | 16 | def __delete__(self, instance): 17 | raise AttributeError("Can't remove attribute.") 18 | 19 | 20 | class PropertyDescriptor(OGMDescriptor): 21 | def __get__(self, instance, owner): 22 | if instance is None: 23 | return owner.__node__[self.name] 24 | return instance.__node__[self.name].value 25 | 26 | def __set__(self, instance, value): 27 | prop = instance.__node__[self.name] 28 | if prop.value != value: 29 | instance.__changed__[self.name] = (prop.value, value) 30 | prop.value = value 31 | 32 | 33 | class RelationDescriptor(OGMDescriptor): 34 | def __get__(self, instance, owner): 35 | return self.name 36 | 37 | def __set__(self, instance, value): 38 | raise ImmutableAttributeError('__relations__', instance) 39 | 40 | 41 | class OGMMeta(type): 42 | def __init__(cls, class_name, bases, attrs): 43 | labels = [] 44 | properties = {} 45 | for base in bases: 46 | try: 47 | labels.extend(base.__node__.labels) 48 | properties.update({key: prop.copy() 49 | for key, prop in base.__node__.items()}) 50 | except AttributeError: 51 | continue 52 | 53 | if not attrs.get('__abstract__'): 54 | labels.append(attrs.get('LABEL') or class_name) 55 | 56 | relations = set() 57 | for attr_name, attr in attrs.items(): 58 | if isinstance(attr, Property): 59 | properties[attr_name] = attr 60 | elif isinstance(attr, Relation): 61 | relations.add(attr_name) 62 | 63 | for prop_name in properties: 64 | setattr(cls, prop_name, PropertyDescriptor(prop_name)) 65 | 66 | cls.__relations__ = RelationDescriptor(tuple(relations)) 67 | cls.__node__ = Node(*labels, **properties) 68 | 69 | try: 70 | graph = cls.graph 71 | except AttributeError: 72 | pass 73 | else: 74 | graph.schema.create(cls) 75 | cls.__node__.graph = graph 76 | 77 | 78 | @six.add_metaclass(OGMMeta) 79 | class OGMBase(object): 80 | def __init__(self, **properties): 81 | self.__changed__ = {} 82 | self.__node__ = self.__node__.copy() 83 | for prop_name, value in properties.items(): 84 | try: 85 | self.__node__[prop_name].value = value 86 | except KeyError: 87 | raise ValueError("Unrecognized argument: '%s'" % prop_name) 88 | for rel_name in self.__relations__: 89 | rel = getattr(self.__class__, rel_name) 90 | setattr(self, rel_name, rel.copy(obj=self)) 91 | 92 | def bind(self, *keys): 93 | self.__node__.bind(*keys) 94 | return self 95 | 96 | @property 97 | def bound_keys(self): 98 | return self.__node__.bound_keys 99 | 100 | @property 101 | def is_bound(self): 102 | return self.__node__.is_bound 103 | 104 | def create(self): 105 | if self.graph is None: 106 | raise DetachedObjectError(self, action='create') 107 | 108 | create = Create(self.__node__) 109 | self.graph.query(create, **create.params) 110 | return self 111 | 112 | def delete(self, detach=True, force=False): 113 | if self.graph is None: 114 | raise DetachedObjectError(self, action='delete') 115 | if not self.is_bound: 116 | self.bind() 117 | if not self.bound_keys and not force: 118 | extra_info = 'To override, use delete_all() or force=True.' 119 | raise UnboundedWriteOperation(self, extra_info) 120 | 121 | match = Match(self.__node__).delete(self.__node__, detach=detach) 122 | self.graph.query(match, **match.params) 123 | 124 | def delete_all(self): 125 | self.bind(None) 126 | self.delete(detach=True, force=True) 127 | 128 | @classmethod 129 | def match(self, **properties): 130 | if self.graph is None: 131 | raise DetachedObjectError(self, action='match') 132 | 133 | matched = self.__node__.copy(**properties) 134 | match = Match(matched).return_(matched) 135 | return Rehydrator(self.graph.query(match, **match.params), 136 | self.graph) 137 | 138 | def merge(self, singleton=False): 139 | if self.graph is None: 140 | raise DetachedObjectError(self, action='merge') 141 | if not self.is_bound: 142 | self.bind() 143 | if not self.bound_keys and not singleton: 144 | extra_info = 'To merge a singleton pass singleton=True.' 145 | raise UnboundedWriteOperation(self, extra_info) 146 | 147 | merge = Merge(self.__node__).on_create().set(*self.__node__.values()) 148 | if self.__changed__: 149 | (merge.on_match() 150 | .set(*(self.__node__[key] for key in self.__changed__))) 151 | merge.return_(self.__node__) 152 | return Rehydrator(self.graph.query(merge, **merge.params), 153 | self.graph).one 154 | 155 | def init_relation(self, rel_type, related, **kw): 156 | unbound = kw.pop('unbound', False) 157 | unbound_start, unbound_end = (unbound or kw.pop(k, False) 158 | for k in ('unbound_start', 'unbound_end')) 159 | rel = Relationship(rel_type, **kw) 160 | rel.start_node = self.__node__.copy(var='self') 161 | if not rel.start_node.is_bound: 162 | rel.start_node.bind() 163 | if not rel.start_node.bound_keys and not unbound_start: 164 | extra_info = 'To override, use unbound_start=True.' 165 | raise UnboundedWriteOperation(rel.start_node, extra_info) 166 | rel.end_node = related.__node__.copy(var='related') 167 | if not rel.end_node.is_bound: 168 | rel.end_node.bind() 169 | if not rel.end_node.bound_keys and not unbound_end: 170 | extra_info = 'To override, use unbound_end=True.' 171 | raise UnboundedWriteOperation(rel.end_node, extra_info) 172 | return rel 173 | 174 | def create_relation(self, rel_type, related, **kw): 175 | rel = self.init_relation(rel_type, related, **kw) 176 | create = ( 177 | (Match(rel.start_node) & 178 | Match(rel.end_node) & 179 | Create(rel)) 180 | .return_(Count(rel)) 181 | ) 182 | return self.graph.query(create, **create.params) 183 | 184 | def delete_relation(self, rel_type, related, **kw): 185 | rel = self.init_relation(rel_type, related, **kw) 186 | match = ( 187 | (Match(rel.start_node) & 188 | Match(rel.end_node) & 189 | Match(rel)) 190 | .delete(rel) 191 | .return_(Count(rel)) 192 | ) 193 | return self.graph.query(match, **match.params) 194 | 195 | def match_relations(self, rel_type, *labels, **properties): 196 | rev = properties.pop('rev', False) 197 | rel = Relationship(rel_type, depth=properties.pop('depth', None)) 198 | if rev: 199 | rel.start_node = Node(*labels, **properties).bind(*properties) 200 | rel.end_node = self.__node__.copy(var='self') 201 | ret = rel.start_node 202 | else: 203 | rel.start_node = self.__node__.copy(var='self') 204 | rel.end_node = Node(*labels, **properties).bind(*properties) 205 | ret = rel.end_node 206 | match = ( 207 | (Match(rel.start_node) & Match(rel.end_node) & Match(rel)) 208 | .return_(ret) 209 | ) 210 | return Rehydrator(self.graph.query(match, **match.params), 211 | self.graph) 212 | 213 | def merge_relation(self, rel_type, related, **kw): 214 | rel = self.init_relation(rel_type, related, **kw) 215 | merge = ( 216 | (Match(rel.start_node) & 217 | Match(rel.end_node) & 218 | Merge(rel)) 219 | .return_(Count(rel)) 220 | ) 221 | return self.graph.query(merge, **merge.params) 222 | -------------------------------------------------------------------------------- /neoalchemy/ogm/relations.py: -------------------------------------------------------------------------------- 1 | import six 2 | 3 | from ..exceptions import ImmutableAttributeError 4 | from ..shared.objects import SetOnceDescriptor 5 | 6 | 7 | class ManyToOneDescriptor(object): 8 | def __init__(self, name, relation): 9 | self.name = name 10 | self.relation = relation.copy() 11 | 12 | def __get__(self, instance, owner): 13 | if instance is None: 14 | self.relation 15 | 16 | return instance.match_relations(self.relation.type, rev=True).one 17 | 18 | def __set__(self, instance, value): 19 | raise ImmutableAttributeError(self.name, instance) 20 | 21 | def __delete__(self, instance): 22 | raise ImmutableAttributeError(self.name, instance) 23 | 24 | 25 | class RelationMeta(type): 26 | def __init__(cls, class_name, bases, attrs): 27 | cls.type = SetOnceDescriptor('type', type=str) 28 | cls.backref = SetOnceDescriptor('backref', type=str) 29 | cls.obj = SetOnceDescriptor('obj') 30 | cls.restricted_types = SetOnceDescriptor('restricted_types', 31 | type=RelationMeta.restricted) 32 | for attr in ('unbound_start', 'unbound_end'): 33 | setattr(cls, attr, SetOnceDescriptor(attr, type=bool)) 34 | super(RelationMeta, cls).__init__(class_name, bases, attrs) 35 | 36 | @staticmethod 37 | def restricted(types): 38 | def get_type(t): 39 | try: 40 | return t.__node__.type 41 | except AttributeError: 42 | return t 43 | 44 | return tuple(get_type(t) for t in types) 45 | 46 | 47 | @six.add_metaclass(RelationMeta) 48 | class Relation(object): 49 | def __init__(self, type_, obj=None, backref=None, 50 | restrict_types=(), **unbound_args): 51 | self.type = type_ 52 | self.obj = obj 53 | self.backref = backref 54 | unbound = unbound_args['unbound'] = bool(unbound_args.get('unbound')) 55 | for arg in ('unbound_start', 'unbound_end'): 56 | unbound_args[arg] = bool(unbound_args.get(arg, unbound)) 57 | self.__unbound_args = unbound_args 58 | self.restricted_types = restrict_types 59 | 60 | def copy(self, obj=None): 61 | return self.__class__(self.type, obj=obj or self.obj, 62 | backref=self.backref, 63 | restrict_types=self.restricted_types, 64 | **dict(self.unbound_args)) 65 | 66 | def create(self, related): 67 | if self.restricted_types: 68 | if not any(label in related.__node__.labels 69 | for label in self.restricted_types): 70 | restricted_types = map(str, self.restricted_types) 71 | raise ValueError("Related object is '%r' but must be one of: " 72 | "'%s'" % (related, 73 | ', '.join(restricted_types))) 74 | return self.obj.create_relation(self.type, related, 75 | **self.__unbound_args) 76 | 77 | def delete(self, related): 78 | return self.obj.delete_relation(self.type, related, 79 | **self.__unbound_args) 80 | 81 | def match(self, *labels, **properties): 82 | return self.obj.match_relations(self.type, *labels, **properties) 83 | 84 | def merge(self, related): 85 | if self.restricted_types: 86 | if not any(label in related.__node__.labels 87 | for label in self.restricted_types): 88 | restricted_types = map(str, self.restricted_types) 89 | raise ValueError("Related object is '%r' but must be one of: " 90 | "'%s'" % (related, 91 | ', '.join(restricted_types))) 92 | return self.obj.merge_relation(self.type, related, 93 | **self.__unbound_args) 94 | 95 | def create_backref(self, cls): 96 | raise NotImplementedError 97 | 98 | @property 99 | def unbound_args(self): 100 | return self.__unbound_args.items() 101 | 102 | 103 | class OneToManyRelation(Relation): 104 | def __init__(self, type_, **kw): 105 | kw['unbound_start'] = kw['unbound'] = False 106 | super(OneToManyRelation, self).__init__(type_, **kw) 107 | 108 | def create_backref(self, cls): 109 | setattr(cls, self.backref, ManyToOneDescriptor(self.backref, self)) 110 | 111 | 112 | class ManyToManyRelation(Relation): 113 | def create_backref(self, cls): 114 | setattr(cls, self.backref, self.copy()) 115 | -------------------------------------------------------------------------------- /neoalchemy/primitives.py: -------------------------------------------------------------------------------- 1 | import six 2 | 3 | from .cypher import Create, Match, Merge, Exists 4 | from .exceptions import DetachedObjectError 5 | from .shared.objects import GraphObject, SetOnceDescriptor 6 | 7 | try: 8 | str = unicode 9 | except NameError: 10 | pass 11 | 12 | 13 | class NodeMeta(type): 14 | def __init__(cls, class_name, bases, attrs): 15 | cls.labels = SetOnceDescriptor('labels', type=lambda x: tuple(set(x))) 16 | cls.type = SetOnceDescriptor('type', type=str) 17 | super(NodeMeta, cls).__init__(class_name, bases, attrs) 18 | 19 | 20 | @six.add_metaclass(NodeMeta) 21 | class Node(GraphObject): 22 | def __init__(self, *labels, **properties): 23 | var = properties.pop('var', None) 24 | if len(labels) == 1 and isinstance(labels[0], Node): 25 | node = labels[0] 26 | properties.update({key: prop.copy() for key, prop in node.items()}) 27 | else: 28 | node = None 29 | super(Node, self).__init__(**properties) 30 | if node is not None: 31 | self.labels = node.labels 32 | self.type = node.type 33 | self.var = var or node.var 34 | else: 35 | self.labels = labels 36 | self.var = var or 'node' 37 | try: 38 | self.type = labels[-1] 39 | except IndexError: 40 | self.type = None 41 | 42 | def copy(self, shallow=False, **properties): 43 | var = properties.pop('var', self.var) 44 | if shallow: 45 | copy = Node(self, graph=self.graph, var=var) 46 | else: 47 | copy = Node(self, graph=self.graph, var=var, 48 | **{key: prop.copy() for key, prop in self.items()}) 49 | if self.is_bound: 50 | copy.bind(*self.bound_keys) 51 | for key, value in properties.items(): 52 | copy[key].value = value 53 | return copy 54 | 55 | @property 56 | def __node__(self): 57 | return self 58 | 59 | def pattern(self, inline_props=False): 60 | labels = ':`%s`' % '`:`'.join(self.labels) if self.labels else '' 61 | if inline_props and self.bound_keys: 62 | props = self.inline_properties 63 | return '(%s%s %s)' % (self.var, labels, props) 64 | else: 65 | return '(%s%s)' % (self.var, labels) 66 | 67 | @property 68 | def schema(self): 69 | return [stmt for prop in self.values() for stmt in prop.schema] 70 | 71 | 72 | class RelationshipMeta(type): 73 | def __init__(cls, class_name, bases, attrs): 74 | cls.type = SetOnceDescriptor('type', type=str) 75 | cls.directed = SetOnceDescriptor('directed', type=bool) 76 | for attr in ('start_node', 'end_node'): 77 | setattr(cls, attr, SetOnceDescriptor(attr, type=Node)) 78 | super(RelationshipMeta, cls).__init__(class_name, bases, attrs) 79 | 80 | 81 | @six.add_metaclass(RelationshipMeta) 82 | class Relationship(GraphObject): 83 | def __init__(self, type, start_node=None, end_node=None, depth=None, 84 | directed=True, var='rel', **properties): 85 | super(Relationship, self).__init__(**properties) 86 | self.var = var 87 | self.type = type 88 | self.start_node = start_node 89 | self.end_node = end_node 90 | self.depth = depth 91 | self.directed = directed 92 | 93 | @property 94 | def depth(self): 95 | return self.__depth 96 | 97 | @depth.setter 98 | def depth(self, depth): 99 | if depth is None: 100 | self.__depth = '' 101 | elif depth == -1: 102 | self.__depth = '*' 103 | else: 104 | try: 105 | self.__depth = '%i' % int(depth) 106 | except ValueError: 107 | self.__depth = '%i..%i' % map(int, depth) 108 | 109 | def exists(self, exists=True): 110 | return Exists(self, exists) 111 | 112 | def pattern(self, inline_props=False): 113 | if self.start_node is None or self.end_node is None: 114 | raise DetachedObjectError(self) 115 | if (self.start_node is not self.end_node and 116 | self.start_node.var == self.end_node.var): 117 | raise ValueError("Relationship start node and end node cannot " 118 | "employ the same Cypher variable.") 119 | 120 | type_spec = (':`%s`' % self.type) if self.type is not None else '' 121 | base_pattern = '-[%s%s%s%%s]-%s' % (self.var, type_spec, self.depth, 122 | '>' if self.directed else '') 123 | if inline_props and self.bound_keys: 124 | pattern = base_pattern % (' ' + self.inline_properties) 125 | else: 126 | pattern = base_pattern % '' 127 | 128 | 129 | return '(%s)%s(%s)' % (self.start_node.var, pattern, self.end_node.var) 130 | -------------------------------------------------------------------------------- /neoalchemy/shared/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwoBitAlchemist/NeoAlchemy/e28634734da3aa6b80eeb439cfc217d9b7de82a8/neoalchemy/shared/__init__.py -------------------------------------------------------------------------------- /neoalchemy/shared/objects.py: -------------------------------------------------------------------------------- 1 | from weakref import WeakKeyDictionary 2 | 3 | import six 4 | 5 | from ..cypher.operations import CypherExpression, CypherOperatorInterface 6 | from ..exceptions import ImmutableAttributeError 7 | 8 | try: 9 | str = unicode 10 | except NameError: 11 | pass 12 | 13 | 14 | class SetOnceDescriptor(object): 15 | def __init__(self, name, type=None): 16 | self.name = name 17 | self.type = type 18 | self.values = WeakKeyDictionary() 19 | 20 | def __get__(self, instance, owner): 21 | if instance is None: 22 | return getattr(owner, self.name) 23 | 24 | return self.values.get(instance) 25 | 26 | def __set__(self, instance, value): 27 | if self.values.get(instance) is not None: 28 | raise ImmutableAttributeError(self.name, instance) 29 | 30 | if self.type is not None and value is not None: 31 | self.values[instance] = self.type(value) 32 | else: 33 | self.values[instance] = value 34 | 35 | def __delete__(self, instance): 36 | raise ImmutableAttributeError(self.name, instance) 37 | 38 | 39 | class GraphObject(object): 40 | def __init__(self, graph=None, **properties): 41 | # __properties must be set before anything else due to usage in 42 | # customized __getattr__/__setattr__ (see below) 43 | self.__properties = {} 44 | self.graph = graph 45 | 46 | for prop_name, prop in properties.items(): 47 | if not isinstance(prop, Property): 48 | prop_val = prop 49 | prop = Property() 50 | prop.value = prop_val 51 | prop.name = prop_name 52 | prop.obj = self 53 | self.__properties[prop.name] = prop 54 | 55 | self.__primary_keys = None 56 | 57 | def bind(self, *keys): 58 | if len(keys) == 1 and keys[0] is None: 59 | self.__primary_keys = () 60 | else: 61 | if any(key not in self.keys() for key in keys): 62 | raise ValueError('%s can only be bound to an existing ' 63 | 'property.' % self.__class__.__name__) 64 | if not keys: 65 | keys = [key for key, prop in self.items() if prop.primary_key] 66 | self.__primary_keys = tuple(keys) 67 | return self 68 | 69 | @property 70 | def bound_keys(self): 71 | return tuple(self.__primary_keys) if self.is_bound else () 72 | 73 | @property 74 | def is_bound(self): 75 | return self.__primary_keys is not None 76 | 77 | @property 78 | def inline_properties(self): 79 | return '{%s}' % ', '.join('%s: {%s}' % (prop, self[prop].param) 80 | for prop in self.bound_keys) 81 | 82 | @property 83 | def properties(self): 84 | return dict(zip(self.keys(), (p.value for p in self.values()))) 85 | 86 | def __getattr__(self, attr): 87 | if not attr.endswith('__properties') and attr in self.__properties: 88 | return self[attr].value 89 | else: 90 | return getattr(super(self.__class__, self), attr) 91 | 92 | def __setattr__(self, attr, value): 93 | if not attr.endswith('__properties') and attr in self.__properties: 94 | self[attr].value = value 95 | else: 96 | super(GraphObject, self).__setattr__(attr, value) 97 | 98 | def __getitem__(self, key): 99 | return self.__properties[key] 100 | 101 | def items(self): 102 | return self.__properties.items() 103 | 104 | def keys(self): 105 | return self.__properties.keys() 106 | 107 | def values(self): 108 | return self.__properties.values() 109 | 110 | def __repr__(self): 111 | return '<%s: %s>' % (self.__class__.__name__, self.pattern()) 112 | 113 | 114 | class PropertyMeta(type): 115 | def __init__(cls, class_name, bases, attrs): 116 | cls.name = SetOnceDescriptor('name', type=str) 117 | cls.type = SetOnceDescriptor('type') 118 | cls.default = SetOnceDescriptor('default') 119 | cls.obj = SetOnceDescriptor('obj', type=PropertyMeta.valid_graph_obj) 120 | for attr in ('unique', 'indexed', 'required', 121 | 'primary_key', 'read_only'): 122 | setattr(cls, attr, SetOnceDescriptor(attr, type=bool)) 123 | super(PropertyMeta, cls).__init__(class_name, bases, attrs) 124 | 125 | @staticmethod 126 | def valid_graph_obj(obj): 127 | if not isinstance(obj, GraphObject): 128 | raise ValueError('Property can only be bound to ' 129 | 'Node or Relationship.') 130 | return obj 131 | 132 | 133 | @six.add_metaclass(PropertyMeta) 134 | class Property(CypherOperatorInterface): 135 | def __init__(self, obj=None, type=str, default=None, value=None, 136 | indexed=False, unique=False, required=False, 137 | primary_key=False, read_only=False): 138 | self.obj = obj 139 | self.type = type 140 | 141 | self.unique = bool(unique) 142 | self.indexed = self.unique or bool(indexed) 143 | self.required = bool(required) 144 | 145 | self.default = default 146 | self.__value = self.value = value 147 | 148 | self.primary_key = primary_key 149 | self.read_only = read_only 150 | 151 | def copy(self): 152 | copy = Property(type=self.type, default=self.default, 153 | indexed=self.indexed, unique=self.unique, 154 | required=self.required, primary_key=self.primary_key, 155 | read_only=self.read_only) 156 | copy.value = self.value 157 | return copy 158 | 159 | @property 160 | def is_bound(self): 161 | return self.obj is not None 162 | 163 | @property 164 | def param(self): 165 | return self.__get_var('_') 166 | 167 | @property 168 | def schema(self): 169 | if not self.is_bound: 170 | raise TypeError('Cannot generate Cypher schema for ' 171 | 'unbound Property.') 172 | 173 | params = { 174 | 'label': self.obj.type, 175 | 'lower_label': self.obj.type.lower(), 176 | 'name': self.name, 177 | } 178 | 179 | schema = [] 180 | if self.unique: 181 | constraint = ('CONSTRAINT ON ( %(lower_label)s:%(label)s ) ' 182 | 'ASSERT %(lower_label)s.%(name)s IS UNIQUE') 183 | schema.append(constraint % params) 184 | elif self.indexed: 185 | schema.append('INDEX ON :%(label)s(%(name)s)' % params) 186 | 187 | if self.required: 188 | constraint = ('CONSTRAINT ON ( %(lower_label)s:%(label)s ) ' 189 | 'ASSERT exists(%(lower_label)s.%(name)s)') 190 | schema.append(constraint % params) 191 | 192 | return schema 193 | 194 | @property 195 | def value(self): 196 | return self.__value 197 | 198 | @value.setter 199 | def value(self, value): 200 | if value is None: 201 | value = self.default() if callable(self.default) else self.default 202 | if value is not None: 203 | value = self.type(value) 204 | self.__value = value 205 | 206 | @property 207 | def var(self): 208 | return self.__get_var() 209 | 210 | def __get_var(self, separator='.'): 211 | if not self.is_bound: 212 | raise TypeError('Cannot create Cypher variable for ' 213 | 'unbound Property.') 214 | 215 | return ''.join((self.obj.var, separator, self.name)) 216 | 217 | def __repr__(self): 218 | return ('' % 219 | (self.name, self.type, self.default, self.value)) 220 | 221 | def __hash__(self): 222 | return id(self) 223 | -------------------------------------------------------------------------------- /neoalchemy/validators.py: -------------------------------------------------------------------------------- 1 | import dateutil.parser 2 | import socket 3 | import uuid 4 | 5 | 6 | def IPv4(ip_addr): 7 | """ 8 | Validate a string as an IPv4 address. See `man 3 inet` for details. 9 | """ 10 | ip_addr = str(ip_addr) 11 | 12 | try: 13 | socket.inet_aton(ip_addr) 14 | except socket.error: 15 | raise ValueError('Invalid IPv4 address: %s' % ip_addr) 16 | 17 | return ip_addr 18 | 19 | 20 | def IPv6(ip_addr): 21 | """ 22 | Validate a string as an IPv6 address. See `man 3 inet_pton` for details. 23 | 24 | Availability is platform-dependent. 25 | """ 26 | ip_addr = str(ip_addr) 27 | 28 | try: 29 | socket.inet_pton(socket.AF_INET6, ip_addr) 30 | except socket.error: 31 | raise ValueError('Invalid IPv6 address: %s' % ip_addr) 32 | except AttributeError: 33 | raise ValueError('IPv6 validation unavailable on this platform.') 34 | 35 | return ip_addr 36 | 37 | 38 | def IP(ip_addr): 39 | """ 40 | Validate an IP address. Tries IPv4 validation and falls back to IPv6 41 | on error. 42 | 43 | Availability of IPv6 validation is platform-dependent. 44 | """ 45 | try: 46 | return IPv4(ip_addr) 47 | except ValueError: 48 | return IPv6(ip_addr) 49 | 50 | 51 | def _parse_date(date_str): 52 | try: 53 | return dateutil.parser.parse(str(date_str)) 54 | except: 55 | raise ValueError("Cannot parse %s as date." % 56 | date_str.__class__.__name__) 57 | 58 | 59 | def isodate(date_): 60 | """ 61 | Fuzzy parse a date and return an ISO-8601 formatted date. 62 | """ 63 | if date_ is None: 64 | return 65 | 66 | return _parse_date(date_).date().isoformat() 67 | 68 | 69 | def isodatetime(datetime_): 70 | """ 71 | Fuzzy parse a date and return an ISO-8601 formatted datetime. 72 | """ 73 | if datetime_ is None: 74 | return 75 | 76 | return _parse_date(datetime_).isoformat() 77 | 78 | 79 | def UUID(id_): 80 | """ 81 | Validator for a valid UUID. Relies on Python's uuid.UUID validator. 82 | """ 83 | if id_ is None: 84 | return 85 | 86 | return str(uuid.UUID(str(id_))) 87 | 88 | 89 | def varchar(length): 90 | """ 91 | Factory for a character length validator of the specified length. 92 | """ 93 | length = int(length) 94 | 95 | def char_length_validator(string): 96 | """ 97 | Validate a string ensuring that it doesn't exceed a maximum length. 98 | """ 99 | if string is None: 100 | return 101 | 102 | string = str(string) 103 | if len(string) > length: 104 | raise ValueError("Value '%s' exceeds character limit " 105 | "of %i." % (string, length)) 106 | return string 107 | 108 | return char_length_validator 109 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = bin include lib lib64 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-dateutil==2.5.3 2 | neo4j-driver==1.0.2 3 | six==1.10.0 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file: README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | long_desc = """ 5 | NeoAlchemy is a SqlAlchemy-like tool for working with the Neo4J graph database 6 | in Python. It is intended to be very easy to use, and intuitively familiar to 7 | anyone who has used SqlAlchemy and/or the Cypher Query Language. 8 | 9 | NeoAlchemy is built on top of the Neo4J Bolt driver and only supports Neo4J 10 | 3.0+ connected over the Bolt protocol. It supports Python 2.7 and 3.3+. 11 | """ 12 | 13 | 14 | setup( 15 | name='neoalchemy', 16 | packages=find_packages(), 17 | version='0.9.0-beta.3', 18 | license='MIT', 19 | 20 | description=('A microframework for Neo4J inspired by SQLAlchemy.'), 21 | keywords=['neo4j', 'graph', 'database', 'cypher', 'ORM', 'OGM'], 22 | 23 | author='Two-Bit Alchemist', 24 | author_email='seregon@gmail.com', 25 | 26 | url='https://github.com/twobitalchemist/neoalchemy', 27 | download_url='https://github.com/twobitalchemist/neoalchemy/tarball/0.9.0b', 28 | 29 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 30 | classifiers=[ 31 | 'Development Status :: 4 - Beta', 32 | 'Intended Audience :: Developers', 33 | 'Topic :: Database :: Front-Ends', 34 | 'License :: OSI Approved :: MIT License', 35 | 'Programming Language :: Python :: 2.7', 36 | 'Programming Language :: Python :: 3.3', 37 | 'Programming Language :: Python :: 3.4', 38 | 'Programming Language :: Python :: 3.5', 39 | 'Programming Language :: Python :: 3.6', 40 | ], 41 | 42 | install_requires=[ 43 | 'python-dateutil', 44 | 'neo4j-driver', 45 | 'six', 46 | ] 47 | ) 48 | -------------------------------------------------------------------------------- /source/OGM-classes.rst: -------------------------------------------------------------------------------- 1 | 2 | 3 | *********** 4 | OGM Classes 5 | *********** 6 | 7 | The OGM is built around subclassing :py:class:`OGMBase`. 8 | 9 | .. py:class:: OGMBase 10 | 11 | .. py:attribute:: __node__ 12 | 13 | The underlying :py:class:`Node` representing this OGM class. 14 | 15 | .. py:method:: bind(*keys) 16 | 17 | Equivalent to ``self.__node__.bind(*keys)``. 18 | 19 | .. py:attribute:: bound_keys 20 | 21 | Equivalent to ``self.__node__.bound_keys``. 22 | 23 | .. py:attribute:: is_bound 24 | 25 | Equivalent to ``self.__node__.is_bound``. 26 | -------------------------------------------------------------------------------- /source/apis.rst: -------------------------------------------------------------------------------- 1 | 2 | 3 | ************************** 4 | Getting to Know NeoAlchemy 5 | ************************** 6 | 7 | NeoAlchemy features both a low-level and a high-level API for working with 8 | Neo4J. The low-level API aims to be **expressive**, offering the user 9 | **flexibility** and **control**. The high-level API is built on top of the 10 | low-level API, and trades control for **automation**. 11 | 12 | You don't have to choose between APIs! The low-level and high-level APIs can be 13 | used in conjunction with one another as well as with manual Cypher querying 14 | with :py:class:`graph.query`. 15 | 16 | 17 | ================= 18 | The Low-Level API 19 | ================= 20 | 21 | NeoAlchemy's low-level API is called :doc:`query-builder`. It is similar in 22 | feel and purpose to the `SqlAlchemy Expression Language`_. In SqlAlchemy, 23 | defining the schema for a table and writing its metadata to the database looks 24 | like this:: 25 | 26 | from sqlalchemy import Table, Column, Integer, String, MetaData 27 | 28 | metadata = MetaData() 29 | 30 | users = Table('users', metadata, 31 | Column('id', Integer, primary_key=True), 32 | Column('name', String), 33 | Column('fullname', String) 34 | ) 35 | 36 | # Emit schema-generating DDL 37 | metadata.create_all(engine) 38 | 39 | 40 | This defines a simple :py:data:`users` table with three columns. A call to 41 | :py:meth:`metadata.create_all()` is required to emit schema-generating DDL, 42 | laying out the empty table. 43 | 44 | The same thing in NeoAlchemy looks like this:: 45 | 46 | import uuid 47 | 48 | from neoalchemy import Node, Property, Graph 49 | from neoalchemy.validators import UUID 50 | 51 | graph = Graph() 52 | 53 | user = Node('User', 54 | uuid=Property(unique=True, type=UUID, default=uuid.uuid4), 55 | name=Property(indexed=True), 56 | full_name=Property(required=True) 57 | ) 58 | 59 | # Emit schema-generating DDL 60 | graph.schema.create(user) 61 | 62 | 63 | This creates a simple :py:class:`Node` with three properties similar to the 64 | above table. Each property represents an available `constraint`_ in Neo4J. The 65 | proper indexes and constraints are created when :py:meth:`graph.schema.create` 66 | is called. 67 | 68 | .. note:: 69 | The ``required`` property represents a `Property Existence constraint`_ 70 | which is only supported in the `Neo4J Enterprise Edition`_. 71 | 72 | Can't wait to learn more? Dive into :doc:`query-builder`. 73 | 74 | 75 | ================== 76 | The High-Level API 77 | ================== 78 | 79 | NeoAlchemy's high-level API is :doc:`schema-OGM`. Python classes are used to 80 | map metadata to the database transparently. It is compared below to 81 | `SqlAlchemy's Declarative ORM`_:: 82 | 83 | from sqlalchemy import Column, Integer, String 84 | from sqlalchemy.ext.declarative import declarative_base 85 | 86 | Base = declarative_base() 87 | 88 | class User(Base): 89 | __tablename__ = 'users' 90 | 91 | id = Column(Integer, primary_key=True) 92 | name = Column(String) 93 | fullname = Column(String) 94 | 95 | # Emit schema-generating DDL 96 | Base.metadata.create_all(engine) 97 | 98 | 99 | The same thing in NeoAlchemy looks like this:: 100 | 101 | import uuid 102 | 103 | from neoalchemy import OGMBase, Property, Graph 104 | from neoalchemy.validators import UUID 105 | 106 | class User(OGMBase): 107 | graph = Graph() 108 | 109 | uuid = Property(unique=True, type=UUID, default=uuid.uuid4) 110 | name = Property(indexed=True) 111 | fullname = Property(required=True) 112 | 113 | # Cypher schema generation emitted automatically 114 | # No user action required 115 | 116 | Notice that unlike SqlAlchemy, we have far less to import and we do not need 117 | to manually trigger metadata creation. We also don't have to explicitly 118 | specify a label for our underlying :py:class:`Node`. NeoAlchemy 119 | uses the name of the class if none is specified. 120 | 121 | .. note:: 122 | Since every class is connected to a graph explicitly via its ``.graph`` 123 | property, users running multiple instances of Neo4J should have no trouble 124 | distinguishing which classes map to which graphs, even if multiple classes 125 | touching different graphs are grouped in the same file. 126 | 127 | Wanna learn more? Skip straight to the :doc:`schema-OGM`. 128 | 129 | .. _SqlAlchemy Expression Language: http://docs.sqlalchemy.org/en/latest/core/tutorial.html 130 | .. _constraint: https://neo4j.com/docs/developer-manual/current/cypher/schema/constraints/ 131 | .. _Property Existence constraint: https://neo4j.com/docs/developer-manual/current/cypher/schema/constraints/#query-constraints-prop-exist-nodes 132 | .. _Neo4J Enterprise Edition: https://neo4j.com/editions/ 133 | .. _SqlAlchemy's Declarative ORM: http://docs.sqlalchemy.org/en/latest/orm/tutorial.html 134 | -------------------------------------------------------------------------------- /source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # NeoAlchemy documentation build configuration file, created by 5 | # sphinx-quickstart on Wed Jun 22 18:16:31 2016. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | 'sphinx.ext.autodoc', 35 | 'sphinx.ext.doctest', 36 | 'sphinx.ext.todo', 37 | 'sphinx.ext.coverage', 38 | 'sphinx.ext.ifconfig', 39 | 'sphinx.ext.viewcode', 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['sphinx_templates'] 44 | 45 | # The suffix(es) of source filenames. 46 | # You can specify multiple suffix as a list of string: 47 | # 48 | # source_suffix = ['.rst', '.md'] 49 | source_suffix = '.rst' 50 | 51 | # The encoding of source files. 52 | # 53 | # source_encoding = 'utf-8-sig' 54 | 55 | # The master toctree document. 56 | master_doc = 'index' 57 | 58 | # General information about the project. 59 | project = 'NeoAlchemy' 60 | copyright = '2016, J. Kevin Bay ' 61 | author = 'J. Kevin Bay ' 62 | 63 | # The version info for the project you're documenting, acts as replacement for 64 | # |version| and |release|, also used in various other places throughout the 65 | # built documents. 66 | # 67 | # The short X.Y version. 68 | version = '0.8' 69 | # The full version, including alpha/beta/rc tags. 70 | release = '0.8.0b' 71 | 72 | # The language for content autogenerated by Sphinx. Refer to documentation 73 | # for a list of supported languages. 74 | # 75 | # This is also used if you do content translation via gettext catalogs. 76 | # Usually you set "language" from the command line for these cases. 77 | language = None 78 | 79 | # There are two options for replacing |today|: either, you set today to some 80 | # non-false value, then it is used: 81 | # 82 | # today = '' 83 | # 84 | # Else, today_fmt is used as the format for a strftime call. 85 | # 86 | # today_fmt = '%B %d, %Y' 87 | 88 | # List of patterns, relative to source directory, that match files and 89 | # directories to ignore when looking for source files. 90 | # This patterns also effect to html_static_path and html_extra_path 91 | exclude_patterns = [] 92 | 93 | # The reST default role (used for this markup: `text`) to use for all 94 | # documents. 95 | # 96 | # default_role = None 97 | 98 | # If true, '()' will be appended to :func: etc. cross-reference text. 99 | # 100 | # add_function_parentheses = True 101 | 102 | # If true, the current module name will be prepended to all description 103 | # unit titles (such as .. function::). 104 | # 105 | # add_module_names = True 106 | 107 | # If true, sectionauthor and moduleauthor directives will be shown in the 108 | # output. They are ignored by default. 109 | # 110 | # show_authors = False 111 | 112 | # The name of the Pygments (syntax highlighting) style to use. 113 | pygments_style = 'sphinx' 114 | 115 | # A list of ignored prefixes for module index sorting. 116 | # modindex_common_prefix = [] 117 | 118 | # If true, keep warnings as "system message" paragraphs in the built documents. 119 | # keep_warnings = False 120 | 121 | # If true, `todo` and `todoList` produce output, else they produce nothing. 122 | todo_include_todos = True 123 | 124 | 125 | # -- Options for HTML output ---------------------------------------------- 126 | 127 | # The theme to use for HTML and HTML Help pages. See the documentation for 128 | # a list of builtin themes. 129 | # 130 | html_theme = 'bizstyle' 131 | 132 | # Theme options are theme-specific and customize the look and feel of a theme 133 | # further. For a list of options available for each theme, see the 134 | # documentation. 135 | # 136 | # html_theme_options = {} 137 | 138 | # Add any paths that contain custom themes here, relative to this directory. 139 | # html_theme_path = [] 140 | 141 | # The name for this set of Sphinx documents. 142 | # " v documentation" by default. 143 | # 144 | # html_title = 'NeoAlchemy v0.0.1' 145 | 146 | # A shorter title for the navigation bar. Default is the same as html_title. 147 | # 148 | # html_short_title = None 149 | 150 | # The name of an image file (relative to this directory) to place at the top 151 | # of the sidebar. 152 | # 153 | # html_logo = None 154 | 155 | # The name of an image file (relative to this directory) to use as a favicon of 156 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 157 | # pixels large. 158 | # 159 | # html_favicon = None 160 | 161 | # Add any paths that contain custom static files (such as style sheets) here, 162 | # relative to this directory. They are copied after the builtin static files, 163 | # so a file named "default.css" will overwrite the builtin "default.css". 164 | html_static_path = ['sphinx_static'] 165 | 166 | # Add any extra paths that contain custom files (such as robots.txt or 167 | # .htaccess) here, relative to this directory. These files are copied 168 | # directly to the root of the documentation. 169 | # 170 | # html_extra_path = [] 171 | 172 | # If not None, a 'Last updated on:' timestamp is inserted at every page 173 | # bottom, using the given strftime format. 174 | # The empty string is equivalent to '%b %d, %Y'. 175 | # 176 | # html_last_updated_fmt = None 177 | 178 | # If true, SmartyPants will be used to convert quotes and dashes to 179 | # typographically correct entities. 180 | # 181 | # html_use_smartypants = True 182 | 183 | # Custom sidebar templates, maps document names to template names. 184 | # 185 | # html_sidebars = {} 186 | 187 | # Additional templates that should be rendered to pages, maps page names to 188 | # template names. 189 | # 190 | # html_additional_pages = {} 191 | 192 | # If false, no module index is generated. 193 | # 194 | # html_domain_indices = True 195 | 196 | # If false, no index is generated. 197 | # 198 | # html_use_index = True 199 | 200 | # If true, the index is split into individual pages for each letter. 201 | # 202 | # html_split_index = False 203 | 204 | # If true, links to the reST sources are added to the pages. 205 | # 206 | # html_show_sourcelink = True 207 | 208 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 209 | # 210 | # html_show_sphinx = True 211 | 212 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 213 | # 214 | # html_show_copyright = True 215 | 216 | # If true, an OpenSearch description file will be output, and all pages will 217 | # contain a tag referring to it. The value of this option must be the 218 | # base URL from which the finished HTML is served. 219 | # 220 | # html_use_opensearch = '' 221 | 222 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 223 | # html_file_suffix = None 224 | 225 | # Language to be used for generating the HTML full-text search index. 226 | # Sphinx supports the following languages: 227 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 228 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' 229 | # 230 | # html_search_language = 'en' 231 | 232 | # A dictionary with options for the search language support, empty by default. 233 | # 'ja' uses this config value. 234 | # 'zh' user can custom change `jieba` dictionary path. 235 | # 236 | # html_search_options = {'type': 'default'} 237 | 238 | # The name of a javascript file (relative to the configuration directory) that 239 | # implements a search results scorer. If empty, the default will be used. 240 | # 241 | # html_search_scorer = 'scorer.js' 242 | 243 | # Output file base name for HTML help builder. 244 | htmlhelp_basename = 'NeoAlchemydoc' 245 | 246 | # -- Options for LaTeX output --------------------------------------------- 247 | 248 | latex_elements = { 249 | # The paper size ('letterpaper' or 'a4paper'). 250 | # 251 | # 'papersize': 'letterpaper', 252 | 253 | # The font size ('10pt', '11pt' or '12pt'). 254 | # 255 | # 'pointsize': '10pt', 256 | 257 | # Additional stuff for the LaTeX preamble. 258 | # 259 | # 'preamble': '', 260 | 261 | # Latex figure (float) alignment 262 | # 263 | # 'figure_align': 'htbp', 264 | } 265 | 266 | # Grouping the document tree into LaTeX files. List of tuples 267 | # (source start file, target name, title, 268 | # author, documentclass [howto, manual, or own class]). 269 | latex_documents = [ 270 | (master_doc, 'NeoAlchemy.tex', 'NeoAlchemy Documentation', 271 | 'J. Kevin Bay \\textless{}seregon@gmail.com\\textgreater{}', 'manual'), 272 | ] 273 | 274 | # The name of an image file (relative to this directory) to place at the top of 275 | # the title page. 276 | # 277 | # latex_logo = None 278 | 279 | # For "manual" documents, if this is true, then toplevel headings are parts, 280 | # not chapters. 281 | # 282 | # latex_use_parts = False 283 | 284 | # If true, show page references after internal links. 285 | # 286 | # latex_show_pagerefs = False 287 | 288 | # If true, show URL addresses after external links. 289 | # 290 | # latex_show_urls = False 291 | 292 | # Documents to append as an appendix to all manuals. 293 | # 294 | # latex_appendices = [] 295 | 296 | # If false, no module index is generated. 297 | # 298 | # latex_domain_indices = True 299 | 300 | 301 | # -- Options for manual page output --------------------------------------- 302 | 303 | # One entry per manual page. List of tuples 304 | # (source start file, name, description, authors, manual section). 305 | man_pages = [ 306 | (master_doc, 'neoalchemy', 'NeoAlchemy Documentation', 307 | [author], 1) 308 | ] 309 | 310 | # If true, show URL addresses after external links. 311 | # 312 | # man_show_urls = False 313 | 314 | 315 | # -- Options for Texinfo output ------------------------------------------- 316 | 317 | # Grouping the document tree into Texinfo files. List of tuples 318 | # (source start file, target name, title, author, 319 | # dir menu entry, description, category) 320 | texinfo_documents = [ 321 | (master_doc, 'NeoAlchemy', 'NeoAlchemy Documentation', 322 | author, 'NeoAlchemy', 'One line description of project.', 323 | 'Miscellaneous'), 324 | ] 325 | 326 | # Documents to append as an appendix to all manuals. 327 | # 328 | # texinfo_appendices = [] 329 | 330 | # If false, no module index is generated. 331 | # 332 | # texinfo_domain_indices = True 333 | 334 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 335 | # 336 | # texinfo_show_urls = 'footnote' 337 | 338 | # If true, do not generate a @detailmenu in the "Top" node's menu. 339 | # 340 | # texinfo_no_detailmenu = False 341 | -------------------------------------------------------------------------------- /source/index.rst: -------------------------------------------------------------------------------- 1 | ########## 2 | NeoAlchemy 3 | ########## 4 | 5 | NeoAlchemy is a microframework for working with the Neo4J graph database 6 | inspired by SqlAlchemy. It is intended to be very easy to use, and intuitively 7 | familiar to anyone who has used SqlAlchemy and/or the Cypher Query Language. 8 | 9 | NeoAlchemy is built on top of the Neo4J Bolt driver and only supports Neo4J 10 | 3.0+ connected over the Bolt protocol. It supports Python 2.7 and 3.3+. 11 | 12 | If you don't have Neo4J installed, `do that first`_. Make sure to install 13 | version 3.0 or higher! If you don't have an environment set up, consider 14 | `setting up a virtual environment`_. 15 | 16 | 17 | .. _do that first: https://neo4j.com/download/ 18 | .. _setting up a virtual environment: http://docs.python-guide.org/en/latest/dev/virtualenvs/ 19 | 20 | ------------------------------------------------------------------------------ 21 | 22 | .. toctree:: 23 | :maxdepth: 3 24 | 25 | intro 26 | apis 27 | query-builder 28 | schema-OGM 29 | schema-classes 30 | querybuilder-classes 31 | OGM-classes 32 | roadmap 33 | -------------------------------------------------------------------------------- /source/intro.rst: -------------------------------------------------------------------------------- 1 | 2 | 3 | *************** 4 | Getting Started 5 | *************** 6 | 7 | ============ 8 | Installation 9 | ============ 10 | 11 | NeoAlchemy is available on `PyPI`_, so it can be installed normally using ``pip 12 | install neoalchemy``. NeoAlchemy is built on top of the `official Neo4J Python 13 | driver`_. If you install from PyPI, this will automatically be installed 14 | alongside it. You can also install the dependencies using ``pip install -r 15 | requirements.txt``. 16 | 17 | `Questions, support requests, comments`_, and `contributions`_ should be 18 | directed to GitHub accordingly. 19 | 20 | 21 | ===================== 22 | Connecting to a Graph 23 | ===================== 24 | 25 | Connecting to a graph is designed to be as easy and painless as possible. In 26 | general, you only have to specify what you have changed from the default 27 | Neo4J settings, and NeoAlchemy will infer the rest. 28 | 29 | For example, if you connect to your graph with the username and password 30 | ``neo4j`` at the default port (7687) on ``localhost``, connecting to a graph is 31 | as simple as:: 32 | 33 | from neoalchemy import Graph 34 | 35 | graph = Graph() 36 | 37 | 38 | On the other hand, if you'd changed your Neo4J password to ``password``, you 39 | could connect like this:: 40 | 41 | graph = Graph(password='password') 42 | 43 | Of course, you can have a completely custom setup if you like. Just pass a URL 44 | and :py:class:`Graph` will parse it for you:: 45 | 46 | graph = Graph('bolt://my_user:my_pass@my.neo4j.server.com:24789') 47 | 48 | You can also pass the username and password in separately if you like:: 49 | 50 | graph = Graph('bolt://my.neo4j.server.com:24789', 51 | user='my_user', password='password') 52 | 53 | .. note:: 54 | NeoAlchemy only supports connecting over `the Bolt protocol`_. This also 55 | means that only Neo4J version 3.0 or higher is supported. 56 | 57 | 58 | .. _PyPI: https://pypi.python.org/pypi/neoalchemy 59 | .. _official Neo4J Python driver: https://neo4j.com/developer/python/ 60 | .. _Questions, support requests, comments: https://github.com/TwoBitAlchemist/NeoAlchemy/issues/new 61 | .. _contributions: https://github.com/TwoBitAlchemist/NeoAlchemy 62 | .. _the Bolt protocol: https://neo4j.com/blog/neo4j-3-0-milestone-1-release/ 63 | -------------------------------------------------------------------------------- /source/query-builder.rst: -------------------------------------------------------------------------------- 1 | 2 | 3 | ******************** 4 | The QueryBuilder API 5 | ******************** 6 | 7 | .. note:: 8 | If you don't need the QueryBuilder API, feel free to skip straight to 9 | learning about the :doc:`schema-OGM`. 10 | 11 | The QueryBuilder API allows you to express familiar Cypher queries using normal 12 | Python objects and operators. To demonstrate it, we will use a simple 13 | :py:class:`Node` like the ``user`` we defined in the previous section. 14 | We'll call this one ``person`` and give it a few simple characteristics:: 15 | 16 | from neoalchemy import Node, Property 17 | 18 | person = Node('Person', 19 | name=Property(indexed=True), 20 | age=Property(type=int), 21 | hair_color=Property() 22 | ) 23 | 24 | Don't forget to create the indexes and constraints you specified using 25 | :py:meth:`graph.schema.create`:: 26 | 27 | graph.schema.create(person) 28 | 29 | .. warning:: 30 | From `the Neo4J Docs`_: 31 | 32 | Indexes in Neo4j are *eventually available*. That means that when you 33 | first create an index the operation returns immediately. The index is 34 | *populating* in the background and so is not immediately available for 35 | querying. When the index has been fully populated it will eventually 36 | come *online*. That means that it is now ready to be used in queries. 37 | 38 | 39 | ====== 40 | Create 41 | ====== 42 | 43 | NeoAlchemy features :doc:`querybuilder-classes` which correspond to `familiar 44 | Cypher verbs`_:: 45 | 46 | from neoalchemy import Create 47 | 48 | Let's start by constructing perhaps the simplest query possible:: 49 | 50 | create = Create(person) 51 | 52 | We can see the query this generates by printing it:: 53 | 54 | >>> print(create) 55 | CREATE (node:`Person`) 56 | SET node.name = {node_name}, node.age = {node_age}, node.hair_color = {node_hair_color} 57 | 58 | NeoAlchemy has automatically applied the ``Person`` label and created 59 | parameters associated with each of the properties we defined. We can see 60 | the current values for each parameter by inspecting the 61 | :py:attr:`~neoalchemy.cypher.SimpleQuery.params` dict:: 62 | 63 | >>> create.params 64 | {'node_age': None, 'node_hair_color': None, 'node_name': None} 65 | 66 | Each parameter is named according to its associated property and the variable 67 | representing its associated node in the underlying Cypher. The default node 68 | variable is ``node``. This can be freely changed to whatever you like:: 69 | 70 | >>> person.var = 'n' 71 | >>> print(Create(person)) 72 | CREATE (n:`Person`) 73 | SET n.name = {n_name}, n.age = {n_age}, n.hair_color = {n_hair_color} 74 | 75 | Properties can be set individually on the Node:: 76 | 77 | >>> person.name = 'Ali' 78 | >>> person.age = 30 79 | >>> person.hair_color = 'red' 80 | 81 | Once you're satisfied, you can write it to the graph using 82 | :py:class:`graph.query`:: 83 | 84 | >>> graph.query(create, **create.params) 85 | 86 | .. note:: 87 | You can run arbitrary queries against the database using 88 | :py:class:`graph.query`. It takes a string as its first argument and 89 | accepts parameters as keyword arguments. It returns a `Neo4J 90 | StatementResult`_. We'll learn more in depth about what :py:class:`Graph` 91 | can do a little later. 92 | 93 | 94 | ===== 95 | Match 96 | ===== 97 | 98 | Now that we've experimented a bit with writing to the database, let's take a 99 | look at how to read data from it:: 100 | 101 | from neoalchemy import Match 102 | 103 | Match has a very similar interface to Create. In the simplest case, Match looks 104 | only at labels:: 105 | 106 | >>> match = Match(person) 107 | >>> print(match) 108 | MATCH (n:`Person`) 109 | 110 | ...but this isn't a full query yet. In order to make this useful, we need to 111 | return something:: 112 | 113 | >>> print(match.return_()) 114 | MATCH (n:`Person`) 115 | RETURN * 116 | 117 | .. note:: 118 | Notice the function is **return_**, not **return**. The latter would cause 119 | a syntax error since ``return`` is a reserved word in Python. 120 | 121 | .. _return-signature: 122 | 123 | ------ 124 | Return 125 | ------ 126 | 127 | If you call :py:meth:`~neoalchemy.cypher.SimpleQuery.return_` with no arguments, 128 | the resulting query will ``RETURN *``, returning everything you have matched. 129 | `For performance reasons`_, however, this is often not the best choice. There 130 | are several ways to return only what you need instead of everything you've 131 | touched. 132 | 133 | ============================ ========================================== ======================== 134 | What to Return NeoAlchemy Cypher Equivalent 135 | ============================ ========================================== ======================== 136 | One node ``return_(person_n)`` ``RETURN n`` 137 | Many nodes ``return_(person_n, person_m)`` ``RETURN n, m`` 138 | One property ``return_(person_n['name'])`` ``RETURN n.name`` 139 | Many properties ``return_(person_n['x'], person_n['y'])`` ``RETURN n.x, n.y`` 140 | Many nodes and properties ``return_(person_m['x'], person_n['y'])`` ``RETURN m.x, n.y`` 141 | ============================ ========================================== ======================== 142 | 143 | .. note:: 144 | The :py:meth:`~neoalchemy.cypher.SimpleQuery.remove` and 145 | :py:meth:`~neoalchemy.cypher.SimpleQuery.delete` methods work the same way. 146 | They correspond to Cypher's `REMOVE`_ and `DELETE`_. Also note that, unlike 147 | in pure Cypher, ``REMOVE`` cannot be used to remove labels through the 148 | NeoAlchemy APIs. 149 | 150 | .. _cypher-expression: 151 | 152 | ----- 153 | Where 154 | ----- 155 | 156 | As with :py:meth:`~neoalchemy.cypher.SimpleQuery.set`, the 157 | :py:meth:`~neoalchemy.cypher.SimpleQuery.where` method can be used to set 158 | parameters one at a time:: 159 | 160 | match = Match(person).where(person['name']=='Ali') 161 | 162 | The first argument is a :py:class:`CypherExpression` object, which is 163 | automatically created when you perform the corresponding Python comparison 164 | using one of the NodeType's Properties. 165 | 166 | ======================= ============================= ======================= 167 | Comparison Type NeoAlchemy CypherExpression Cypher Equivalent 168 | ======================= ============================= ======================= 169 | Equal to ``person['name'] == 'Ali'`` ``n.name = 'Ali'`` 170 | Not equal to ``person['name'] != 'Ali'`` ``n.name <> 'Ali'`` 171 | Greater than ``person['age'] > 29`` ``n.age > 29`` 172 | Greater than or equal ``person['age'] >= 29`` ``n.age >= 29`` 173 | Lesser than ``person['age'] < 29`` ``n.age < 29`` 174 | Lesser than or equal ``person['age'] <= 29`` ``n.age <= 29`` 175 | ======================= ============================= ======================= 176 | 177 | .. _chaining: 178 | 179 | ======== 180 | Chaining 181 | ======== 182 | 183 | An important concept in NeoAlchemy is method chaining. Most methods ``return 184 | self`` so you can call them like so:: 185 | 186 | match = Match(person).where(person['name']=='Ali').return_(person['name']) 187 | 188 | This makes for convenient and expressive one-liners. However, this also means 189 | that state is easy to build up over time and as part of larger algorithms:: 190 | 191 | match = Match(person) 192 | # ... some code ... 193 | match.where(person['age']=age) 194 | # ... more code... 195 | match.return_(ret_params) 196 | 197 | .. _binding: 198 | 199 | ====================== 200 | Binding & Primary Keys 201 | ====================== 202 | 203 | Often instead of specifying individual where clauses, it will be preferable to 204 | match on a set of the Node's Properties that define what it is. One way to do 205 | this in NeoAlchemy is by *binding* the Node to those Properties:: 206 | 207 | >>> print(Match(person)) 208 | MATCH (n:`Person`) 209 | >>> ali = Node('Person', name='Ali', var='n') 210 | >>> print(Match(ali.bind('name'))) 211 | MATCH (n:`Person`) 212 | WHERE n.name = {n_name} 213 | 214 | Setting certain Properties as the *primary keys* of a Node will give it a 215 | default binding:: 216 | 217 | >>> person = Node('Person', name=Property(primary_key=True), var='n') 218 | >>> print(Match(person.bind())) 219 | MATCH (n:`Person`) 220 | WHERE n.name = {n_name} 221 | 222 | ============= 223 | Relationships 224 | ============= 225 | 226 | So far, we have only worked with nodes. NeoAlchemy also provides a 227 | :py:class:`Relationship` class. Relationships in NeoAlchemy always have a 228 | type. To create a relationship:: 229 | 230 | >>> from neoalchemy import Relationship 231 | >>> knows = Relationship('KNOWS') 232 | 233 | Relationships aren't much good without start and end nodes, though. Let's 234 | connect two Person nodes who know each other:: 235 | 236 | >>> knows.start_node = person.copy(var='a') 237 | >>> knows.end_node = person.copy(var='b') 238 | >>> print(Create(knows)) 239 | CREATE (a)-[rel:`KNOWS`]->(b) 240 | 241 | But wait! This isn't the right Cypher query. In order to use relationships 242 | with Cypher query builders, we must first build up match statements to grab 243 | the right end nodes. 244 | 245 | ================ 246 | Set Combinations 247 | ================ 248 | 249 | Not all Cypher queries are one line, and neither are all NeoAlchemy queries. 250 | You can use Python's set operators to combine several NeoAlchemy objects into 251 | multi-line queries before returning. The ``&`` (`set intersection`_) operator 252 | is used for line-by-line cominbation. The most typical way this will be used 253 | is with relationships in order to fully specify them for Creating or Matching:: 254 | 255 | >>> ali = Node('Person', name='Ali', var='ali').bind('name') 256 | >>> frank = Node('Person', name='Frank', var='frank').bind('name') 257 | >>> knows = Relationship('KNOWS', ali, frank) 258 | >>> print(Match(ali) & Match(frank) & Match(knows)) 259 | MATCH (ali:`Person`) 260 | WHERE ali.name = {ali_name} 261 | MATCH (frank:`Person`) 262 | WHERE frank.name = {frank_name} 263 | MATCH (ali)-[rel:`KNOWS`]->(frank) 264 | 265 | The ``|`` (`set union`_) operator is used for ``UNION ALL``. To borrow an 266 | example from the Cypher docs:: 267 | 268 | >>> movie = Node('Movie', title=Property(primary_key=True), var='movie') 269 | >>> actor = Node('Actor', name=Property(primary_key=True), var='actor') 270 | >>> acted_in = Relationship('ACTED_IN', actor, movie) 271 | >>> directed = Relationship('DIRECTED', actor, movie) 272 | >>> actor_match = ( 273 | ... (Match(actor) & Match(movie) & Match(acted_in)) 274 | ... .return_(actor['name'], movie['title']) 275 | ... ) 276 | >>> director_match = ( 277 | ... (Match(actor) & Match(movie) & Match(directed)) 278 | ... .return_(actor['name'], movie['title']) 279 | ... ) 280 | >>> print(actor_match | director_match) 281 | MATCH (actor:`Actor`) 282 | MATCH (movie:`Movie`) 283 | MATCH (actor)-[rel:`ACTED_IN`]->(movie) 284 | RETURN actor.name, movie.title 285 | UNION ALL 286 | MATCH (actor:`Actor`) 287 | MATCH (movie:`Movie`) 288 | MATCH (actor)-[rel:`DIRECTED`]->(movie) 289 | RETURN actor.name, movie.title 290 | 291 | If you instead want ``UNION``, use the ``^`` (`exclusive or`_) operator. 292 | 293 | .. note:: 294 | ``UNION`` must be performed on queries with very similar result structures. 295 | You must take this into account when building your queries. 296 | 297 | 298 | .. _the Neo4J Docs: http://neo4j.com/docs/developer-manual/current/#graphdb-neo4j-schema-indexes 299 | .. _familiar Cypher verbs: https://neo4j.com/docs/developer-manual/current/cypher/clauses/ 300 | .. _Neo4J StatementResult: https://neo4j.com/docs/api/python-driver/current/session.html?highlight=statementresult#neo4j.v1.StatementResult 301 | .. _For performance reasons: https://neo4j.com/docs/developer-manual/current/cypher/query-tuning 302 | .. _REMOVE: https://neo4j.com/docs/developer-manual/current/cypher/clauses/remove/ 303 | .. _DELETE: https://neo4j.com/docs/developer-manual/current/cypher/clauses/delete/ 304 | .. _set intersection: https://docs.python.org/3/library/stdtypes.html#set.intersection 305 | .. _set union: https://docs.python.org/3/library/stdtypes.html#set.union 306 | .. _exclusive or: https://docs.python.org/3/library/stdtypes.html#set.symmetric_difference 307 | -------------------------------------------------------------------------------- /source/querybuilder-classes.rst: -------------------------------------------------------------------------------- 1 | 2 | 3 | ******************** 4 | QueryBuilder Classes 5 | ******************** 6 | 7 | .. py:module:: neoalchemy.cypher 8 | 9 | .. py:class:: SimpleQuery 10 | 11 | Parent class to :py:class:`Create`, :py:class:`Match`, and 12 | :py:class:`Merge`. 13 | 14 | .. py:attribute:: params 15 | 16 | A dict mapping query parameter names to their current values. 17 | 18 | .. py:method:: __str__ 19 | 20 | Return the underlying Cypher query, which has been automatically 21 | parametrized. 22 | 23 | .. py:method:: delete(args=None, detach=False) 24 | 25 | Set the ``DELETE`` clause for the query. 26 | 27 | :param args: See the docs for :ref:`return-signature`. 28 | :param bool detach: If set, ``DETACH DELETE`` instead. 29 | :return: The object itself, to support :ref:`chaining`. 30 | 31 | .. py:method:: limit(n) 32 | 33 | Set the ``LIMIT`` clause for the query. 34 | 35 | :param int n: The argument to ``LIMIT`` 36 | 37 | .. py:method:: order_by(args, desc=False) 38 | 39 | Set the ``ORDER BY`` clause for the query. 40 | 41 | :param args: See the docs for :ref:`return-signature`. 42 | :param bool desc: If set, sort ``DESC``. Otherwise, sort ``ASC``. 43 | 44 | .. py:method:: remove(args=None) 45 | 46 | Set the ``REMOVE`` clause for the query. 47 | 48 | :param args: See the docs for :ref:`return-signature`. 49 | :return: The object itself, to support :ref:`chaining`. 50 | 51 | .. py:method:: return_(args=None, distinct=False) 52 | 53 | Set the ``RETURN`` clause for the query. 54 | 55 | :param args: See the docs for :ref:`return-signature`. 56 | :param bool distinct: If set, ``RETURN DISTINCT`` instead. 57 | :return: The object itself, to support :ref:`chaining`. 58 | 59 | .. py:method:: set(property, value) 60 | 61 | Set a property to a value. Can be called multiple times. 62 | 63 | :param Property property: The property to set 64 | :param any value: The value of the property to set 65 | :return: The object itself, to support :ref:`chaining`. 66 | 67 | .. py:method:: skip(n) 68 | 69 | Set the ``SKIP`` clause for the query. 70 | 71 | :param int n: The argument to ``SKIP`` 72 | 73 | .. py:method:: where(exprs, or_=False) 74 | 75 | Set the ``WHERE`` clause for the query. 76 | 77 | :param CypherExpression exprs: See the docs for :ref:`cypher-expression` 78 | :param bool or\_: If set, this will be joined with the preceding ``WHERE`` 79 | clause using ``OR`` instead of ``AND``. 80 | :return: The object itself, to support :ref:`chaining`. 81 | 82 | 83 | .. py:class:: Create(obj) 84 | 85 | :param GraphObject obj: The :py:class:`GraphObject` to create. 86 | 87 | 88 | .. py:class:: Match(obj, optional=False) 89 | 90 | :param GraphObject obj: The :py:class:`GraphObject` to match. 91 | :param bool optional: If set, ``OPTIONAL MATCH`` instead. 92 | 93 | 94 | .. py:class:: Merge(obj) 95 | 96 | :param GraphObject obj: The :py:class:`GraphObject` to merge. 97 | 98 | .. :py:method:: on_create() 99 | 100 | Insert an ``ON CREATE`` where called. 101 | 102 | .. :py:method:: on_match() 103 | 104 | Insert an ``ON MATCH`` where called. 105 | -------------------------------------------------------------------------------- /source/roadmap.rst: -------------------------------------------------------------------------------- 1 | 2 | 3 | ************** 4 | RoadMap to 1.0 5 | ************** 6 | 7 | ====== 8 | 0.8.0b 9 | ====== 10 | 11 | - First Public Release 12 | 13 | 14 | === 15 | 0.9 16 | === 17 | 18 | - QueryBuilder :py:class:`~neoalchemy.cypher.Merge` support for 19 | ``ON CREATE``, ``ON MATCH`` 20 | - :py:meth:`neoalchemy.OGMBase.match` / :py:meth:`neoalchemy.OGMBase.merge` 21 | - Schema OGM Basic Relation support 22 | - ``WITH`` 23 | - Neo4J Built-in Functions 24 | - Arbitrary Relationship Depth support in Schema OGM 25 | - Hydrate: ``neo4j.v1.StatementResult`` => Schema OGM class 26 | (:py:class:`OGMBase` subclass) 27 | - Aggregation 28 | 29 | 30 | === 31 | 1.0 32 | === 33 | 34 | - To be determined 35 | 36 | 37 | ========== 38 | Beyond 1.0 39 | ========== 40 | 41 | - Smarter queries 42 | - CASE expressions (CASE WHEN ... THEN ... ELSE ... END) 43 | - String Support (``STARTS WITH``, ``ENDS WITH``, =~) 44 | -------------------------------------------------------------------------------- /source/schema-OGM.rst: -------------------------------------------------------------------------------- 1 | 2 | 3 | ********** 4 | Schema OGM 5 | ********** 6 | 7 | The Schema OGM (Object Graph Mapper) allows you to use Python classes to specify 8 | the structure of Nodes. We could make an OGM version of ``person`` like this:: 9 | 10 | from neoalchemy import OGMBase, Property, Graph 11 | 12 | class Person(OGMBase): 13 | graph = Graph() 14 | 15 | name = Property(unique=True) 16 | age = Property(type=int) 17 | hair_color = Property() 18 | 19 | We don't need to call :py:meth:`graph.schema.create` here; it is automatically 20 | done for us by the OGMBase `metaclass`_. **As soon as your class definition is 21 | read into memory, the appropriate indexes and/or constraints are written to the 22 | graph.** This is designed for convenient use in apps, especially in Frameworks 23 | like `Flask`_, so that the graph is kept up to date as soon as your class is 24 | imported for the first time. 25 | 26 | If this behavior is undesired, it can be avoided by not attaching a graph 27 | instance to the class until after it is created:: 28 | 29 | class Person(OGMBase): 30 | name = Property(unique=True) 31 | age = Property(type=int) 32 | hair_color = Property() 33 | 34 | Person.graph = Graph() 35 | 36 | If you do it this way, you must remember to write the schema yourself later 37 | using :py:meth:`graph.schema.create`:: 38 | 39 | Person.graph.schema.create(Person) 40 | 41 | .. warning:: 42 | From `the Neo4J Docs`_: 43 | 44 | Indexes in Neo4j are *eventually available*. That means that when you 45 | first create an index the operation returns immediately. The index is 46 | *populating* in the background and so is not immediately available for 47 | querying. When the index has been fully populated it will eventually 48 | come *online*. That means that it is now ready to be used in queries. 49 | 50 | 51 | =================== 52 | OGM Class Instances 53 | =================== 54 | 55 | The OGM is built on top of :doc:`query-builder`, but uses several convenience 56 | methods so that you don't have to build your own queries. When you instantiate, 57 | you can choose between passing in parameter values to the constructor:: 58 | 59 | person = Person(name='Ali', age=29, hair_color='red') 60 | 61 | Or you can set them individually:: 62 | 63 | person = Person() 64 | person.name, person.age = ('Ali', 29) 65 | person.hair_color = 'red' 66 | 67 | 68 | -------------- 69 | Create & Merge 70 | -------------- 71 | 72 | A node instance can be created on the graph like so:: 73 | 74 | person.create() 75 | 76 | This uses :doc:`query-builder` to :py:class:`~neoalchemy.cypher.Create` the 77 | node with its currently set values. 78 | 79 | .. note:: 80 | If you set any parameter to ``None``, Neo4J will not create the property 81 | on the node. Likewise, setting any property to ``NULL`` is equivalent to 82 | removing it. This is a limitation of Cypher. 83 | 84 | Usually you will want to merge instead:: 85 | 86 | person.merge() 87 | 88 | Internally, merge uses `ON CREATE and ON MATCH`_ to either set all properties 89 | (on create) or just those which have changed (on match). 90 | 91 | The biggest difference between creating and merging is that merge will 92 | keep updating the same node, while create will keep spawning new instances. 93 | 94 | 95 | .. _metaclass: http://stackoverflow.com/q/100003/ 96 | .. _Flask: http://flask.pocoo.org/ 97 | .. _the Neo4J Docs: http://neo4j.com/docs/developer-manual/current/#graphdb-neo4j-schema-indexes 98 | .. _ON CREATE and ON MATCH: http://neo4j.com/docs/developer-manual/current/cypher/clauses/merge/#_use_on_create_and_on_match 99 | -------------------------------------------------------------------------------- /source/schema-classes.rst: -------------------------------------------------------------------------------- 1 | 2 | 3 | ************** 4 | Schema Classes 5 | ************** 6 | 7 | .. py:class:: GraphObject(graph=None, **properties) 8 | 9 | The base class for :py:class:`Node` and :py:class:`Relationship` 10 | 11 | :param Graph graph: The graph instance to which the object should write. 12 | :param str var: The Cypher variable representing the object. 13 | :param Property properties: The :py:class:`Property` objects associated 14 | with the Node. String arguments are 15 | automatically converted into Property objects 16 | of the default string type. 17 | 18 | .. py:method:: bind(*keys) 19 | 20 | :param str keys: The names of properties to bind to. If none are provided, 21 | the default is to bind to all Properties marked as 22 | primary keys, if any. 23 | 24 | .. py:attribute:: bound_keys 25 | 26 | A tuple representing the keys to which the object is currently bound. 27 | 28 | .. py:attribute:: is_bound 29 | 30 | A boolean indicating whether or not the object is bound. This can be 31 | True even if bound_keys is empty. 32 | 33 | .. py:attribute:: properties 34 | 35 | A dict representing properties and their current values. **Note that 36 | editing this dict does not set properties!** 37 | 38 | .. py:method:: items() 39 | 40 | A dict-like method providing a tuple of pairs of property names and 41 | :py:class:`Property` objects. 42 | 43 | .. py:method:: keys() 44 | 45 | A dict-like method providing a tuple of names of attributes which 46 | correspond to Properties. 47 | 48 | .. py:method:: values() 49 | 50 | A dict-like method providing a tuple of :py:class:`Property` objects 51 | defined for the object. 52 | 53 | .. py:class:: Node(*labels, **properties) 54 | 55 | The core of the low-level QueryBuilder API. Represents a Node in Neo4J 56 | along with its labels and properties. 57 | 58 | :param str labels: The labels for the Node. These should be defined from 59 | least to most specific. 60 | :param str var: The Cypher variable representing the node. 61 | :param Property properties: The :py:class:`Property` objects associated 62 | with the Node. String arguments are 63 | automatically converted into Property objects 64 | of the default string type. 65 | 66 | .. py:attribute:: type 67 | 68 | The primary label that defines the Node. 69 | 70 | .. py:attribute:: labels 71 | 72 | The set of all labels shared by the Node's members. 73 | 74 | 75 | .. py:class:: Relationship(type, start_node=None, end_node=None, depth=None,\ 76 | directed=True, var='rel', **properties) 77 | 78 | :param str type: The relationship's type, which in NeoAlchemy is required. 79 | :param Node start_node: The node represented by a, in ``(a)-[:TYPE]->(b)``. 80 | :param Node end_node: The node represented by b, in ``(a)-[:TYPE]->(b)``. 81 | :param depth: A variable property to control the depth of the relationship 82 | when used as part of a Cypher statement. ``-1`` indicates 83 | "infinite" depth (``*``), any positive integer ``n`` 84 | represents that depth (``*n``), and any tuple of positive 85 | integers ``(m, n)`` represents a range (``*m..n``). 86 | :param bool directed: Whether or not the relation is directed. Default 87 | is True (directed relation). Note that some 88 | operations (such as merge) become effectively 89 | arbitrary on undirected relationships. This is a 90 | limitation of Neo4J. 91 | :param str var: The Cypher variable representing the relationship. 92 | :param Property properties: The :py:class:`Property` objects associated 93 | with the Node. String arguments are 94 | automatically converted into Property objects 95 | of the default string type. 96 | 97 | 98 | .. py:method:: exists(exists=True) 99 | 100 | Returns a CypherExpression corresponding to the ``EXISTS`` function 101 | in Cypher. 102 | 103 | :param bool exists: If False, do ``NOT EXISTS`` instead. Default is True. 104 | 105 | 106 | .. py:class:: Property(obj=None, type=str, default=None, value=None,\ 107 | indexed=False, unique=False, required=False,\ 108 | primary_key=False, read_only=False) 109 | 110 | Represents an optionally constrained property on a :py:class:`GraphObject`. 111 | 112 | :param GraphObject obj: The object to which the Property will be bound. 113 | :param callable type: Any callable used to convert a property value's type. 114 | Typically a built-in Python type or something found 115 | in the ``neoalchemy.validators`` module. 116 | :param default: The default value for the property if not specified. This 117 | can be a callable. Either the value (or the return value 118 | if callable) must pass validation given by type. 119 | :param value: The starting value for the property. Must pass validation 120 | given by type. 121 | :param bool indexed: If set, `create an index`_ for the property. 122 | :param bool unique: If set, `create a unique constraint`_ for the property. 123 | This implies ``indexed=True``. 124 | :param bool required: If set, `create a property existence constraint`_ for 125 | the property. Only available with Neo4J Enterprise. 126 | :param bool primary_key: If set, the property is one of the object's 127 | default bindings when 128 | :py:method::`~neoalchemy.GraphObject.bind` 129 | is called with no arguments. 130 | :param bool read_only: Not yet implemented. 131 | 132 | .. py:attribute:: value 133 | 134 | The current value of the property. 135 | 136 | .. py:attribute:: var 137 | 138 | The current Cypher variable for the property. A property must be 139 | bound to a :py:class:`GraphObject` to compute its Cypher variable. 140 | 141 | 142 | .. py:class:: Graph 143 | 144 | .. py:method:: delete_all 145 | 146 | Issues ``MATCH (all) DETACH DELETE all``, completely clearing the 147 | graph. 148 | 149 | It should go without saying that **this will delete all of your data!** 150 | 151 | .. py:attribute:: query(query, **params) 152 | 153 | Run an arbitrary query against the graph, with optional parameters. 154 | 155 | When not called, returns a reference to the Graph's 156 | :py:class:`graph.query` object. 157 | 158 | :param query: An object that stringifies to a Cypher query. 159 | :param params: The values for the query's parameters. 160 | :return: A Neo4J StatementResult corresponding to the issued query. 161 | :rtype: `neo4j.v1.StatementResult`_ 162 | 163 | .. py:attribute:: schema 164 | 165 | A reference to the Graph's :py:class:`graph.schema` object. 166 | 167 | .. py:method:: session 168 | 169 | Returns `a session from the underlying driver`_'s pool. 170 | 171 | 172 | .. py:class:: graph.query 173 | 174 | .. py:method:: graph.query.all 175 | 176 | Returns the result of ``MATCH (all) RETURN all``. 177 | 178 | .. py:method:: graph.query.log(query, params) 179 | 180 | Log the given query and parameters. For other options, see 181 | :py:class:`graph.query.log`. 182 | 183 | 184 | .. py:class:: graph.query.log 185 | 186 | .. py:attribute:: MAX_SIZE 187 | 188 | *int* The maximum number of log entries to store. 189 | 190 | 191 | .. py:class:: graph.schema 192 | 193 | .. py:method:: graph.schema.create(node) 194 | 195 | Create the schema for the Node. 196 | 197 | :param Node node: The Node instance to add to the schema 198 | :rtype: None 199 | 200 | .. py:method:: graph.schema.drop(node) 201 | 202 | Drop the schema for the Node. 203 | 204 | :param Node node: The Node instance to drop from the schema 205 | :rtype: None 206 | 207 | .. py:attribute:: graph.schema.constraints 208 | 209 | Get current graph constraints lazily. 210 | 211 | On first access, this fetches from the database. Afterwards, call 212 | :py:meth:`update()` to refresh. 213 | 214 | .. py:attribute:: graph.schema.indexes 215 | 216 | Get current graph indexes lazily. 217 | 218 | On first access, this fetches from the database. Afterwards, call 219 | :py:meth:`update()` to refresh. 220 | 221 | .. py:attribute:: graph.schema.labels 222 | 223 | Get current graph labels lazily. 224 | 225 | On first access, this fetches from the database. Afterwards, call 226 | :py:meth:`update()` to refresh. 227 | 228 | .. py:attribute:: graph.schema.ls 229 | 230 | Cypher statements for currently defined schema. 231 | 232 | .. py:method:: graph.schema.update 233 | 234 | Refresh constraints, indexes, and labels. 235 | 236 | 237 | .. _a session from the underlying driver: https://neo4j.com/docs/developer-manual/current/drivers/run-statements/#_sessions 238 | .. _neo4j.v1.StatementResult: https://neo4j.com/docs/api/python-driver/current/session.html?highlight=statementresult#neo4j.v1.StatementResult 239 | .. _create an index: https://neo4j.com/docs/developer-manual/current/cypher/schema/index/ 240 | .. _create a unique constraint: https://neo4j.com/docs/developer-manual/current/cypher/schema/constraints/ 241 | .. _create a property existence constraint: https://neo4j.com/docs/developer-manual/current/#query-constraints-prop-exist-nodes 242 | -------------------------------------------------------------------------------- /tests/MockProject/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwoBitAlchemist/NeoAlchemy/e28634734da3aa6b80eeb439cfc217d9b7de82a8/tests/MockProject/__init__.py -------------------------------------------------------------------------------- /tests/MockProject/addresses.py: -------------------------------------------------------------------------------- 1 | from neoalchemy import Property 2 | 3 | from .graph import OGMTestClass 4 | 5 | 6 | class Address(OGMTestClass): 7 | street_level = Property() 8 | city = Property() 9 | 10 | 11 | class DomesticAddress(Address): 12 | state = Property() 13 | zip_code = Property() 14 | 15 | 16 | class InternationalAddress(Address): 17 | postal_code = Property() 18 | -------------------------------------------------------------------------------- /tests/MockProject/customers.py: -------------------------------------------------------------------------------- 1 | from neoalchemy import Property, OneToManyRelation, ManyToManyRelation 2 | 3 | from .graph import OGMTestClass 4 | 5 | 6 | class Customer(OGMTestClass): 7 | username = Property() 8 | email = Property(primary_key=True) 9 | addresses = ManyToManyRelation('HAS_ADDRESS', restrict_types=('Address',), 10 | backref='customer') 11 | orders = OneToManyRelation('PLACED_ORDER', restrict_types=('Order',), 12 | backref='customer') 13 | -------------------------------------------------------------------------------- /tests/MockProject/graph.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | 4 | from neoalchemy import Graph, OGMBase 5 | from neoalchemy.graph import Query, Schema 6 | 7 | 8 | class FakeQuery(Query): 9 | def run(self, query, **params): 10 | self.log(query, params) 11 | return () 12 | 13 | 14 | class FakeQueryContextManager(object): 15 | def __init__(self, fake_query): 16 | self.__query = fake_query 17 | 18 | def __enter__(self): 19 | return self.__query 20 | 21 | def __exit__(self, *args): 22 | pass 23 | 24 | 25 | class FakeSchema(Schema): 26 | def drop_all(self): 27 | pass 28 | 29 | 30 | class FakeGraph(Graph): 31 | def __init__(self, **kw): 32 | kw['encrypted'] = False 33 | self.__connected = False 34 | if 'NEOALCHEMY_TEST_GRAPH' in os.environ: 35 | kw.update({ 36 | 'user': os.environ.get('NEOALCHEMY_TEST_USER'), 37 | 'password': os.environ.get('NEOALCHEMY_TEST_PASS'), 38 | 'url': os.environ.get('NEOALCHEMY_TEST_URL'), 39 | }) 40 | self.__connected = True 41 | super(FakeGraph, self).__init__(**kw) 42 | self.__query = FakeQuery(self) 43 | self.__schema = FakeSchema(self) 44 | 45 | @property 46 | def query(self): 47 | if self.__connected: 48 | return super(FakeGraph, self).query 49 | return self.__query 50 | 51 | @property 52 | def schema(self): 53 | if self.__connected: 54 | return super(FakeGraph, self).schema 55 | return self.__schema 56 | 57 | def session(self): 58 | if self.__connected: 59 | return super(FakeGraph, self).session() 60 | return FakeQueryContextManager(self.__query) 61 | 62 | 63 | class OGMTestClass(OGMBase): 64 | graph = FakeGraph() 65 | __abstract__ = True 66 | -------------------------------------------------------------------------------- /tests/MockProject/orders.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | import uuid 3 | 4 | from neoalchemy import OneToManyRelation, Property 5 | from neoalchemy.validators import UUID 6 | 7 | from .graph import OGMTestClass 8 | 9 | 10 | def money(value): 11 | if value is None: 12 | return 13 | return '%.2f' % Decimal(value) 14 | 15 | 16 | class OrderItem(OGMTestClass): 17 | line_item = Property(primary_key=True) 18 | price = Property(type=money, primary_key=True) 19 | 20 | 21 | class Order(OGMTestClass): 22 | id = Property(type=UUID, default=uuid.uuid4, primary_key=True, 23 | indexed=True) 24 | items = OneToManyRelation('HAS_ITEM', restrict_types=(OrderItem,), 25 | backref='order') 26 | -------------------------------------------------------------------------------- /tests/cypher/test_create.py: -------------------------------------------------------------------------------- 1 | """Cypher Create tests""" 2 | import pytest 3 | 4 | from neoalchemy import Create, Match, Node, Property, Relationship 5 | from neoalchemy.exceptions import DetachedObjectError 6 | 7 | 8 | def test_create_node_no_props(): 9 | user = Node('User') 10 | create = Create(user) 11 | assert str(create) == 'CREATE (node:`User`)' 12 | assert not create.params 13 | 14 | 15 | def test_create_node_one_prop(): 16 | expected_stmt = ( 17 | 'CREATE (node:`User`)\n' 18 | ' SET node.name = {node_name}' 19 | ) 20 | user = Node('User', name=Property()) 21 | create = Create(user) 22 | assert str(create) == expected_stmt 23 | assert len(create.params) == 1 24 | assert 'node_name' in create.params 25 | assert create.params['node_name'] is None 26 | user.name = 'Frank' 27 | create = Create(user) 28 | assert str(create) == expected_stmt 29 | assert len(create.params) == 1 30 | assert 'node_name' in create.params 31 | assert create.params['node_name'] == 'Frank' 32 | 33 | 34 | def test_create_node_two_props(): 35 | user = Node('User', name=Property(), age=Property(type=int)) 36 | create = Create(user) 37 | assert len(create.params) == 2 38 | assert 'node_name' in create.params 39 | assert create.params['node_name'] is None 40 | assert 'node_age' in create.params 41 | assert create.params['node_age'] is None 42 | create.set(user['name'] == 'Frank') 43 | assert len(create.params) == 2 44 | assert 'node_name' in create.params 45 | assert create.params['node_name'] == 'Frank' 46 | assert 'node_age' in create.params 47 | assert create.params['node_age'] is None 48 | create.set(user['age'] == '29') 49 | assert len(create.params) == 2 50 | assert 'node_name' in create.params 51 | assert create.params['node_name'] == 'Frank' 52 | assert 'node_age' in create.params 53 | assert create.params['node_age'] == 29 54 | 55 | 56 | def test_create_node_two_labels(): 57 | expected_stmts = ( 58 | 'CREATE (node:`Person`:`User`)\n' 59 | ' SET node.name = {node_name}', 60 | 'CREATE (node:`User`:`Person`)\n' 61 | ' SET node.name = {node_name}' 62 | ) 63 | user = Node('User', 'Person', name=Property()) 64 | create = Create(user) 65 | assert str(create) in expected_stmts 66 | 67 | 68 | def test_create_relation_to_nowhere(): 69 | rel = Relationship('KNOWS', Node('User')) 70 | with pytest.raises(DetachedObjectError): 71 | create = Create(rel) 72 | 73 | 74 | def test_create_relationship(): 75 | rel = Relationship('KNOWS', Node('User'), Node('User')) 76 | with pytest.raises(ValueError): 77 | create = Create(rel) 78 | rel.end_node.var = 'end_node' 79 | assert str(Create(rel)) == 'CREATE (node)-[rel:`KNOWS`]->(end_node)' 80 | 81 | 82 | def test_create_undirected_relationship(): 83 | rel = Relationship('KNOWS', Node('User', var='m'), Node('User', var='n'), 84 | var='r', directed=False) 85 | assert str(Create(rel)) == 'CREATE (m)-[r:`KNOWS`]-(n)' 86 | 87 | 88 | def test_full_relationship_create(): 89 | expected_query = ( 90 | 'MATCH (m:`User`)\n' 91 | 'MATCH (n:`User`)\n' 92 | 'CREATE (m)-[rel:`KNOWS`]->(n)' 93 | ) 94 | user_m = Node('User', name=Property(), var='m') 95 | user_n = user_m.copy(var='n') 96 | rel = Relationship('KNOWS', user_m, user_n) 97 | query = Match(user_m) & Match(user_n) & Create(rel) 98 | assert str(query) == expected_query 99 | assert len(query.params) == 0 100 | 101 | 102 | def test_logical_cypher_expressions(): 103 | person = Node('Person', name=Property(), var='n') 104 | match = Match(person).where(person['name']=='Alice') 105 | assert str(match) == 'MATCH (n:`Person`)\n WHERE n.name = {n_name}' 106 | assert len(match.params) == 1 107 | assert 'n_name' in match.params 108 | assert match.params['n_name'] == 'Alice' 109 | match = Match(person).where(person['name']!='Alice') 110 | assert str(match) == 'MATCH (n:`Person`)\n WHERE n.name <> {n_name}' 111 | assert len(match.params) == 1 112 | assert 'n_name' in match.params 113 | assert match.params['n_name'] == 'Alice' 114 | match = Match(person).where(person['name']>='Alice') 115 | assert str(match) == 'MATCH (n:`Person`)\n WHERE n.name >= {n_name}' 116 | assert len(match.params) == 1 117 | assert 'n_name' in match.params 118 | assert match.params['n_name'] == 'Alice' 119 | match = Match(person).where(person['name']<='Alice') 120 | assert str(match) == 'MATCH (n:`Person`)\n WHERE n.name <= {n_name}' 121 | assert len(match.params) == 1 122 | assert 'n_name' in match.params 123 | assert match.params['n_name'] == 'Alice' 124 | match = Match(person).where(person['name']<'Alice') 125 | assert str(match) == 'MATCH (n:`Person`)\n WHERE n.name < {n_name}' 126 | assert len(match.params) == 1 127 | assert 'n_name' in match.params 128 | assert match.params['n_name'] == 'Alice' 129 | match = Match(person).where(person['name']>'Alice') 130 | assert str(match) == 'MATCH (n:`Person`)\n WHERE n.name > {n_name}' 131 | assert len(match.params) == 1 132 | assert 'n_name' in match.params 133 | assert match.params['n_name'] == 'Alice' 134 | 135 | 136 | def test_complex_logical_cypher_expressions(): 137 | Person = Node('Person', name=Property(), hair_color=Property(), var='n') 138 | expected_match = [ 139 | 'MATCH (n:`Person`)', 140 | ' WHERE n.name = {n_name}', 141 | ' AND n.hair_color = {n_hair_color}' 142 | ] 143 | 144 | match = (Match(Person) 145 | .where(Person['name']=='Alice') 146 | .where(Person['hair_color']=='red')) 147 | assert str(match) == '\n'.join(expected_match) 148 | assert match.params == {'n_name': 'Alice', 'n_hair_color': 'red'} 149 | 150 | match = (Match(Person) 151 | .where((Person['name']=='Alice'), (Person['hair_color']=='red'))) 152 | assert str(match) == '\n'.join((expected_match[0], 153 | ' '.join((expected_match[1], 154 | expected_match[2].lstrip())))) 155 | assert match.params == {'n_name': 'Alice', 'n_hair_color': 'red'} 156 | 157 | Person = Node('Person', name=Property(), hair_color=Property(), 158 | age=Property(type=int), var='n') 159 | expected_match.append(' AND n.age = {n_age}') 160 | match = (Match(Person) 161 | .where((Person['name']=='Alice'), 162 | (Person['hair_color']=='red'), 163 | (Person['age']=='29'))) 164 | assert str(match) == '\n'.join((expected_match[0], 165 | ' '.join((expected_match[1], 166 | expected_match[2].lstrip(), 167 | expected_match[3].lstrip())))) 168 | assert match.params == {'n_name': 'Alice', 'n_hair_color': 'red', 169 | 'n_age': 29} 170 | 171 | match = (Match(Person) 172 | .where(Person['name']=='Alice') 173 | .where(Person['hair_color']=='red') 174 | .where(Person['age']==29)) 175 | assert str(match) == '\n'.join(expected_match) 176 | assert match.params == {'n_name': 'Alice', 'n_hair_color': 'red', 177 | 'n_age': 29} 178 | 179 | expected_match[3] = expected_match[3].replace('AND', ' OR') 180 | match = (Match(Person) 181 | .where((Person['name']=='Alice'), 182 | (Person['hair_color']=='red')) 183 | .where(Person['age']==29, or_=True)) 184 | assert str(match) == '\n'.join((expected_match[0], 185 | ' '.join((expected_match[1], 186 | expected_match[2].lstrip())), 187 | expected_match[3])) 188 | assert match.params == {'n_name': 'Alice', 'n_hair_color': 'red', 189 | 'n_age': 29} 190 | 191 | match = (Match(Person) 192 | .where(Person['name']=='Alice') 193 | .where(Person['hair_color']=='red') 194 | .where(Person['age']==29, or_=True)) 195 | assert str(match) == '\n'.join(expected_match) 196 | assert match.params == {'n_name': 'Alice', 'n_hair_color': 'red', 197 | 'n_age': 29} 198 | 199 | 200 | def test_arithmetic_cypher_expressions(): 201 | Person = Node('Person', age=Property(type=int), var='n') 202 | expected_stmt = ['MATCH (n:`Person`)', ''] 203 | 204 | match = Match(Person).where((Person['age'] + 5) == 23) 205 | expected_stmt[1] = ' WHERE n.age + {n_age} = {param0}' 206 | assert str(match) == '\n'.join(expected_stmt) 207 | assert match.params['n_age'] == 5 208 | assert match.params['param0'] == 23 209 | match = Match(Person).where((5 + Person['age']) == 23) 210 | assert str(match) == '\n'.join(expected_stmt) 211 | assert match.params['n_age'] == 5 212 | assert match.params['param0'] == 23 213 | 214 | match = Match(Person).where((Person['age'] - 4) == 13) 215 | expected_stmt[1] = ' WHERE n.age - {n_age} = {param0}' 216 | assert str(match) == '\n'.join(expected_stmt) 217 | assert match.params['n_age'] == 4 218 | assert match.params['param0'] == 13 219 | match = Match(Person).where((4 - Person['age']) == 13) 220 | expected_stmt[1] = ' WHERE {n_age} - n.age = {param0}' 221 | assert str(match) == '\n'.join(expected_stmt) 222 | assert match.params['n_age'] == 4 223 | assert match.params['param0'] == 13 224 | 225 | match = Match(Person).where((Person['age'] * 5) == 23) 226 | expected_stmt[1] = ' WHERE n.age * {n_age} = {param0}' 227 | assert str(match) == '\n'.join(expected_stmt) 228 | assert match.params['n_age'] == 5 229 | assert match.params['param0'] == 23 230 | match = Match(Person).where((5 * Person['age']) == 23) 231 | assert str(match) == '\n'.join(expected_stmt) 232 | assert match.params['n_age'] == 5 233 | assert match.params['param0'] == 23 234 | 235 | match = Match(Person).where((Person['age'] / 4) == 13) 236 | expected_stmt[1] = ' WHERE n.age / {n_age} = {param0}' 237 | assert str(match) == '\n'.join(expected_stmt) 238 | assert match.params['n_age'] == 4 239 | assert match.params['param0'] == 13 240 | match = Match(Person).where((4 / Person['age']) == 13) 241 | expected_stmt[1] = ' WHERE {n_age} / n.age = {param0}' 242 | assert str(match) == '\n'.join(expected_stmt) 243 | assert match.params['n_age'] == 4 244 | assert match.params['param0'] == 13 245 | 246 | match = Match(Person).where((Person['age'] % 4) == 13) 247 | expected_stmt[1] = ' WHERE n.age % {n_age} = {param0}' 248 | assert str(match) == '\n'.join(expected_stmt) 249 | assert match.params['n_age'] == 4 250 | assert match.params['param0'] == 13 251 | match = Match(Person).where((4 % Person['age']) == 13) 252 | expected_stmt[1] = ' WHERE {n_age} % n.age = {param0}' 253 | assert str(match) == '\n'.join(expected_stmt) 254 | assert match.params['n_age'] == 4 255 | assert match.params['param0'] == 13 256 | 257 | match = Match(Person).where((Person['age'] ** 4) == 13) 258 | expected_stmt[1] = ' WHERE n.age ^ {n_age} = {param0}' 259 | assert str(match) == '\n'.join(expected_stmt) 260 | assert match.params['n_age'] == 4 261 | assert match.params['param0'] == 13 262 | match = Match(Person).where((4 ** Person['age']) == 13) 263 | expected_stmt[1] = ' WHERE {n_age} ^ n.age = {param0}' 264 | assert str(match) == '\n'.join(expected_stmt) 265 | assert match.params['n_age'] == 4 266 | assert match.params['param0'] == 13 267 | -------------------------------------------------------------------------------- /tests/cypher/test_match.py: -------------------------------------------------------------------------------- 1 | """Cypher Match tests""" 2 | from neoalchemy import Match, Merge, Node, Property, Relationship 3 | 4 | 5 | def test_full_match(): 6 | expected_stmt = [ 7 | 'MATCH (m:`Person`)', 8 | ' WHERE m.name = {m_name}', 9 | 'MATCH (n:`Person`)', 10 | ' WHERE n.name = {n_name}', 11 | 'MATCH (m)-[rel:`KNOWS`]->(n)', 12 | ] 13 | person_m = Node('Person', name='Alice', var='m').bind('name') 14 | person_n = person_m.copy(name='Bob', var='n') 15 | knows = Relationship('KNOWS', person_m, person_n) 16 | match = Match(person_m) & Match(person_n) & Match(knows) 17 | assert str(match) == '\n'.join(expected_stmt) 18 | assert len(match.params) == 2 19 | assert 'm_name' in match.params 20 | assert match.params['m_name'] == 'Alice' 21 | assert 'n_name' in match.params 22 | assert match.params['n_name'] == 'Bob' 23 | match.return_().order_by(person_n['name']).skip(1).limit(1) 24 | expected_stmt += [ 25 | 'RETURN *', 26 | 'ORDER BY n.name', 27 | 'SKIP 1', 28 | 'LIMIT 1', 29 | ] 30 | assert str(match) == '\n'.join(expected_stmt) 31 | assert len(match.params) == 2 32 | assert 'm_name' in match.params 33 | assert match.params['m_name'] == 'Alice' 34 | assert 'n_name' in match.params 35 | assert match.params['n_name'] == 'Bob' 36 | expected_stmt[-3] += ' DESC' 37 | for _ in range(3): 38 | match.pop() 39 | match.order_by(person_n['name'], desc=True).skip(1).limit(1) 40 | assert str(match) == '\n'.join(expected_stmt) 41 | assert len(match.params) == 2 42 | assert 'm_name' in match.params 43 | assert match.params['m_name'] == 'Alice' 44 | assert 'n_name' in match.params 45 | assert match.params['n_name'] == 'Bob' 46 | 47 | 48 | def test_return(): 49 | n = Node('Person', name=Property(), x=Property(), y=Property(), var='n') 50 | match = Match(n) 51 | query = 'MATCH (n:`Person`)' 52 | assert str(match.return_()) == '\n'.join((query, 'RETURN *')) 53 | match.pop() 54 | assert str(match.return_(n)) == '\n'.join((query, 'RETURN n')) 55 | match.pop() 56 | m = Node('Person', x=Property(), var='m') 57 | match &= Match(m) 58 | query = '\n'.join((query, 'MATCH (m:`Person`)')) 59 | assert str(match.return_(n, m)) == '\n'.join((query, 'RETURN n, m')) 60 | match.pop() 61 | assert str(match.return_(n['name'])) == '\n'.join((query, 62 | 'RETURN n.name')) 63 | match.pop() 64 | assert str(match.return_(n['x'], n['y'])) == '\n'.join((query, 65 | 'RETURN n.x, n.y')) 66 | match.pop() 67 | assert str(match.return_(m['x'], n['y'])) == '\n'.join((query, 68 | 'RETURN m.x, n.y')) 69 | 70 | 71 | def test_cypher_methods(): 72 | n = Node('Person', name='Mr. N', var='n') 73 | match = Match(n) 74 | query = 'MATCH (n:`Person`)' 75 | assert str(match.delete(n)) == '\n'.join((query, 'DELETE n')) 76 | match.pop() 77 | assert str(match.delete(n['name'])) == '\n'.join((query, 'DELETE n.name')) 78 | match.pop() 79 | assert str(match.delete(n, detach=True)) == '\n'.join((query, 80 | 'DETACH DELETE n')) 81 | match.pop() 82 | assert str(match.remove(n['name'])) == '\n'.join((query, 'REMOVE n.name')) 83 | match.pop() 84 | assert (str(match.delete(n).with_(n).return_(n['name'])) == 85 | '\n'.join((query, 'DELETE n', 'WITH n', 'RETURN n.name'))) 86 | 87 | 88 | def test_union_and_union_all(): 89 | n = Node('Person', name='Mr. N', var='n') 90 | m = n.copy(var='m', name='Mrs. M') 91 | expected_stmt = [ 92 | 'MATCH (n:`Person`)', 93 | 'UNION', 94 | 'MATCH (m:`Person`)', 95 | ] 96 | assert str(Match(n) ^ Match(m)) == '\n'.join(expected_stmt) 97 | expected_stmt[1] += ' ALL' 98 | assert str(Match(n) | Match(m)) == '\n'.join(expected_stmt) 99 | 100 | 101 | def test_matching_anonymous_relationship(): 102 | person_m = Node('Person', var='m') 103 | person_n = person_m.copy(var='n') 104 | rel = Relationship(None, person_m, person_n) 105 | match = Match(rel) 106 | assert str(match) == 'MATCH (m)-[rel]->(n)' 107 | 108 | 109 | def test_optional_match(): 110 | person_m = Node('Person', var='m') 111 | person_n = person_m.copy(var='n') 112 | rel = Relationship(None, person_m, person_n) 113 | match = Match(rel, optional=True) 114 | assert str(match) == 'OPTIONAL MATCH (m)-[rel]->(n)' 115 | 116 | 117 | def test_where_and_set(): 118 | person = Node('Person', name='Ali', age=Property(value=29, type=int), 119 | hair_color='red', var='n') 120 | expected_match = [ 121 | 'MATCH (n:`Person`)', 122 | ' WHERE n.name = {n_name}', 123 | ] 124 | match = Match(person.bind('name')) 125 | assert str(match) == '\n'.join(expected_match) 126 | assert len(match.params) == 1 127 | assert 'n_name' in match.params 128 | assert match.params['n_name'] == 'Ali' 129 | expected_match = [ 130 | 'MATCH (n:`Person`)', 131 | ' WHERE n.name = {n_name} AND n.age = {n_age}', 132 | ] 133 | match = Match(person.bind('name', 'age')) 134 | try: 135 | assert str(match) == '\n'.join(expected_match) 136 | except AssertionError: 137 | expected_match.pop() 138 | expected_match.append( 139 | ' WHERE n.age = {n_age}' 140 | ' AND n.name = {n_name}' 141 | ) 142 | assert str(match) == '\n'.join(expected_match) 143 | assert len(match.params) == 2 144 | assert 'n_name' in match.params 145 | assert match.params['n_name'] == 'Ali' 146 | assert 'n_age' in match.params 147 | assert match.params['n_age'] == 29 148 | match.set(person['age'] == 30) 149 | expected_match.append(' SET n.age = {param0}') 150 | assert str(match) == '\n'.join(expected_match) 151 | assert len(match.params) == 3 152 | assert 'n_name' in match.params 153 | assert match.params['n_name'] == 'Ali' 154 | assert 'n_age' in match.params 155 | assert match.params['n_age'] == 29 156 | assert 'param0' in match.params 157 | assert match.params['param0'] == 30 158 | match.set(person['name'] == 'Alison') 159 | expected_match.append(' SET n.name = {param1}') 160 | assert str(match) == '\n'.join(expected_match) 161 | assert len(match.params) == 4 162 | assert 'n_name' in match.params 163 | assert match.params['n_name'] == 'Ali' 164 | assert 'n_age' in match.params 165 | assert match.params['n_age'] == 29 166 | assert 'param0' in match.params 167 | assert match.params['param0'] == 30 168 | assert 'param1' in match.params 169 | assert match.params['param1'] == 'Alison' 170 | 171 | 172 | def test_where_and_or(): 173 | person = Node('Person', name='Ali', age=Property(value=29, type=int), 174 | hair_color='red', var='n') 175 | expected_match = [ 176 | 'MATCH (n:`Person`)', 177 | ' WHERE n.name = {n_name}', 178 | ] 179 | match = Match(person.bind('name')) 180 | assert str(match) == '\n'.join(expected_match) 181 | assert len(match.params) == 1 182 | assert 'n_name' in match.params 183 | assert match.params['n_name'] == 'Ali' 184 | match.where(person['age']) == 29 185 | expected_match.append(' AND n.age = {n_age}') 186 | assert str(match) == '\n'.join(expected_match) 187 | assert len(match.params) == 2 188 | assert 'n_name' in match.params 189 | assert match.params['n_name'] == 'Ali' 190 | assert 'n_age' in match.params 191 | assert match.params['n_age'] == 29 192 | match = Match(person.bind('name')) 193 | match.where(person['age'], or_=True) == 29 194 | expected_match.pop() 195 | expected_match.append(' OR n.age = {n_age}') 196 | assert str(match) == '\n'.join(expected_match) 197 | assert len(match.params) == 2 198 | assert 'n_name' in match.params 199 | assert match.params['n_name'] == 'Ali' 200 | assert 'n_age' in match.params 201 | assert match.params['n_age'] == 29 202 | 203 | 204 | def test_merge(): 205 | kev = Node('Person', name='Kevin', var='kev').bind('name') 206 | ali = Node('Person', name='Ali', var='ali').bind('name') 207 | rel = Relationship('LOVES', kev, ali, duration='forever').bind('duration') 208 | expected_stmt = [ 209 | 'MATCH (kev:`Person`)', 210 | ' WHERE kev.name = {kev_name}', 211 | 'MATCH (ali:`Person`)', 212 | ' WHERE ali.name = {ali_name}', 213 | 'MERGE (kev)-[rel:`LOVES` {duration: {rel_duration}}]->(ali)' 214 | ] 215 | query = Match(kev) & Match(ali) & Merge(rel) 216 | assert str(query) == '\n'.join(expected_stmt) 217 | assert len(query.params) == 3 218 | assert 'kev_name' in query.params 219 | assert query.params['kev_name'] == 'Kevin' 220 | assert 'ali_name' in query.params 221 | assert query.params['ali_name'] == 'Ali' 222 | assert 'rel_duration' in query.params 223 | assert query.params['rel_duration'] == 'forever' 224 | 225 | 226 | def test_merge_on_create_on_match(): 227 | person = Node('Person', name=Property()) 228 | merge = ( 229 | Merge(person) 230 | .on_create() 231 | .set(person['name'] == 'Fred') 232 | .on_match() 233 | .set(person['name'] == 'Bob') 234 | ) 235 | expected_stmt = '\n'.join(( 236 | 'MERGE (node:`Person`)', 237 | 'ON CREATE', 238 | ' SET node.name = {node_name}', 239 | 'ON MATCH', 240 | ' SET node.name = {param0}' 241 | )) 242 | assert str(merge) == expected_stmt 243 | assert len(merge.params) == 2 244 | assert 'node_name' in merge.params 245 | assert merge.params['node_name'] == 'Fred' 246 | assert 'param0' in merge.params 247 | assert merge.params['param0'] == 'Bob' 248 | -------------------------------------------------------------------------------- /tests/graph/test_connection.py: -------------------------------------------------------------------------------- 1 | """Graph (Connection Class) Tests""" 2 | import pytest 3 | 4 | from neoalchemy import Graph 5 | 6 | 7 | def test_default_connection(): 8 | graph = Graph() 9 | assert graph.driver.url == 'bolt://localhost' 10 | assert graph.driver.host == 'localhost' 11 | assert graph.driver.port is None 12 | assert graph.driver.config['auth'].scheme == 'basic' 13 | assert graph.driver.config['auth'].principal == 'neo4j' 14 | assert graph.driver.config['auth'].credentials == 'neo4j' 15 | 16 | 17 | def test_http_connection(): 18 | with pytest.warns(UserWarning): 19 | graph = Graph('http://localhost') 20 | assert graph.driver.url == 'bolt://localhost' 21 | assert graph.driver.host == 'localhost' 22 | assert graph.driver.port is None 23 | assert graph.driver.config['auth'].scheme == 'basic' 24 | assert graph.driver.config['auth'].principal == 'neo4j' 25 | assert graph.driver.config['auth'].credentials == 'neo4j' 26 | 27 | 28 | def test_auth_token_in_connection(): 29 | graph = Graph('bolt://user:pass@localhost') 30 | assert graph.driver.url == 'bolt://localhost' 31 | assert graph.driver.host == 'localhost' 32 | assert graph.driver.port is None 33 | assert graph.driver.config['auth'].scheme == 'basic' 34 | assert graph.driver.config['auth'].principal == 'user' 35 | assert graph.driver.config['auth'].credentials == 'pass' 36 | 37 | 38 | def test_full_connection_string(): 39 | graph = Graph('bolt://user:pass@localhost:7474') 40 | assert graph.driver.url == 'bolt://localhost:7474' 41 | assert graph.driver.host == 'localhost' 42 | assert graph.driver.port == 7474 43 | assert graph.driver.config['auth'].scheme == 'basic' 44 | assert graph.driver.config['auth'].principal == 'user' 45 | assert graph.driver.config['auth'].credentials == 'pass' 46 | 47 | 48 | def test_separate_user_pass(): 49 | graph = Graph('bolt://localhost:7474', user='user', password='pass') 50 | assert graph.driver.url == 'bolt://localhost:7474' 51 | assert graph.driver.host == 'localhost' 52 | assert graph.driver.port == 7474 53 | assert graph.driver.config['auth'].scheme == 'basic' 54 | assert graph.driver.config['auth'].principal == 'user' 55 | assert graph.driver.config['auth'].credentials == 'pass' 56 | -------------------------------------------------------------------------------- /tests/graph/test_schema.py: -------------------------------------------------------------------------------- 1 | """Schema object tests""" 2 | import pytest 3 | 4 | from neoalchemy import Node, Property 5 | 6 | 7 | def test_simple_labeled_node(): 8 | node = Node('Node') 9 | assert node.labels == ('Node',) 10 | # cannot reset label once created 11 | with pytest.raises(AttributeError): 12 | node.labels = ('bob',) 13 | assert not node.schema 14 | 15 | 16 | def test_node_one_index(): 17 | person = Node('Person', name=Property(indexed=True)) 18 | assert person.schema == ['INDEX ON :Person(name)'] 19 | assert person['name'].indexed 20 | assert not person['name'].unique 21 | assert not person['name'].required 22 | 23 | 24 | def test_node_one_unique(): 25 | person = Node('Person', SSN=Property(unique=True)) 26 | assert person.schema == ['CONSTRAINT ON ( person:Person ) ' 27 | 'ASSERT person.SSN IS UNIQUE'] 28 | assert person['SSN'].indexed 29 | assert person['SSN'].unique 30 | assert not person['SSN'].required 31 | 32 | 33 | def test_node_one_required(): 34 | person = Node('Person', name=Property(required=True)) 35 | assert person.schema == ['CONSTRAINT ON ( person:Person ) ' 36 | 'ASSERT exists(person.name)'] 37 | assert not person['name'].indexed 38 | assert not person['name'].unique 39 | assert person['name'].required 40 | 41 | 42 | def test_node_one_required_and_indexed(): 43 | person = Node('Person', name=Property(required=True, indexed=True)) 44 | assert person.schema == ['INDEX ON :Person(name)', 45 | 'CONSTRAINT ON ( person:Person ) ' 46 | 'ASSERT exists(person.name)'] 47 | assert person['name'].indexed 48 | assert not person['name'].unique 49 | assert person['name'].required 50 | 51 | 52 | def test_node_one_required_and_unique(): 53 | person = Node('Person', name=Property(required=True, unique=True)) 54 | assert person.schema == ['CONSTRAINT ON ( person:Person ) ' 55 | 'ASSERT person.name IS UNIQUE', 56 | 'CONSTRAINT ON ( person:Person ) ' 57 | 'ASSERT exists(person.name)'] 58 | assert person['name'].indexed 59 | assert person['name'].unique 60 | assert person['name'].required 61 | -------------------------------------------------------------------------------- /tests/ogm/test_nodes.py: -------------------------------------------------------------------------------- 1 | """Schema OGM tests""" 2 | import os 3 | 4 | import pytest 5 | 6 | from neoalchemy import Graph 7 | from MockProject.customers import Customer 8 | from MockProject.graph import FakeGraph 9 | from MockProject.orders import Order, OrderItem 10 | 11 | 12 | graph_test = pytest.mark.skipif(os.environ.get('NEOALCHEMY_TEST_GRAPH') is None, 13 | reason='No graph connection available.') 14 | 15 | 16 | @pytest.fixture 17 | def graph(): 18 | return FakeGraph() 19 | 20 | 21 | @pytest.fixture 22 | def clear_graph(graph): 23 | graph.schema.drop_all() 24 | graph.delete_all() 25 | return graph 26 | 27 | 28 | @graph_test 29 | def test_a_customer_places_an_order(clear_graph): 30 | customer = Customer(username='seregon', email='seregon@gmail.com').merge() 31 | assert isinstance(customer, Customer) 32 | assert customer.username == 'seregon' 33 | assert customer.email == 'seregon@gmail.com' 34 | order = Order().merge() 35 | order_id = order.id 36 | assert isinstance(order, Order) 37 | assert order.id is not None 38 | customer.orders.merge(order) 39 | assert len(list(customer.orders.match())) == 1 40 | customer = Customer.match(email='seregon@gmail.com').one 41 | assert isinstance(customer, Customer) 42 | assert customer.username == 'seregon' 43 | assert customer.email == 'seregon@gmail.com' 44 | assert len(list(customer.orders.match())) == 1 45 | order = customer.orders.match().one 46 | assert isinstance(order, Order) 47 | assert order.id == order_id 48 | assert order.customer.email == 'seregon@gmail.com' 49 | item = OrderItem(line_item='bread', price='2.00').create() 50 | other_item = OrderItem(line_item='milk', price='3.00').create() 51 | order.items.merge(item) 52 | order.items.merge(other_item) 53 | assert len(list(order.items.match())) == 2 54 | -------------------------------------------------------------------------------- /tests/ogm/test_validators.py: -------------------------------------------------------------------------------- 1 | """Tests for type validators""" 2 | import uuid 3 | 4 | import pytest 5 | 6 | from neoalchemy.validators import * 7 | 8 | 9 | def test_date_validators(): 10 | assert isodate('Mar 24, 1985') == '1985-03-24' 11 | assert isodate('03/24/1985') == '1985-03-24' 12 | assert isodate('1985-03-24') == '1985-03-24' 13 | assert isodate('3-24-85') == '1985-03-24' 14 | with pytest.raises(ValueError): 15 | isodate('chicken nugget') 16 | assert isodatetime('Mar 24, 1985 10:50 PM') == '1985-03-24T22:50:00' 17 | assert isodatetime('Mar 24, 1985 10:50 p.m.') == '1985-03-24T22:50:00' 18 | assert isodatetime('24 Mar, 1985 22:50') == '1985-03-24T22:50:00' 19 | with pytest.raises(ValueError): 20 | isodatetime('cat loaf') 21 | 22 | 23 | def test_uuid_validator(): 24 | assert UUID(uuid.uuid1()) 25 | assert UUID(uuid.uuid4()) 26 | with pytest.raises(ValueError): 27 | assert UUID('12345') 28 | 29 | 30 | def test_varchar_validator(): 31 | max5 = varchar(5) 32 | assert max5('hello') == 'hello' 33 | assert max5('hi') == 'hi' 34 | with pytest.raises(ValueError): 35 | assert max5('hello!') 36 | max3 = varchar(3) 37 | assert max3('hi') == 'hi' 38 | with pytest.raises(ValueError): 39 | assert max3('hello') 40 | 41 | 42 | def test_IP_validators(): 43 | assert IPv4('192.168.1.1') == '192.168.1.1' 44 | assert IPv4('0.0.0.0') == '0.0.0.0' 45 | with pytest.raises(ValueError): 46 | assert IPv4('123.456.789.000') 47 | assert IPv6('::1') == '::1' 48 | assert IPv6('::FFFF:123:456:789:000') == '::FFFF:123:456:789:000' 49 | assert IPv6('0:0:0:0:0:0:0:1') == '0:0:0:0:0:0:0:1' 50 | with pytest.raises(ValueError): 51 | assert IPv6('2345235:5923058209385:wtfisthis') 52 | assert IP('192.168.1.1') == '192.168.1.1' 53 | assert IP('0.0.0.0') == '0.0.0.0' 54 | assert IP('::1') == '::1' 55 | assert IP('::FFFF:123:456:789:000') == '::FFFF:123:456:789:000' 56 | assert IP('0:0:0:0:0:0:0:1') == '0:0:0:0:0:0:0:1' 57 | with pytest.raises(ValueError): 58 | assert IP('Good morning starshine the earth says hello') 59 | -------------------------------------------------------------------------------- /tests/test_set_once.py: -------------------------------------------------------------------------------- 1 | """Test SetOnceDescriptor implementation""" 2 | import pytest 3 | 4 | from neoalchemy import Node, Property, Relationship 5 | from neoalchemy.exceptions import ImmutableAttributeError 6 | 7 | 8 | def test_property_set_once(): 9 | prop = Property() 10 | with pytest.raises(ImmutableAttributeError): 11 | prop.type = int 12 | with pytest.raises(ImmutableAttributeError): 13 | prop.indexed = False 14 | with pytest.raises(ImmutableAttributeError): 15 | prop.unique = True 16 | with pytest.raises(ImmutableAttributeError): 17 | prop.required = 5 18 | with pytest.raises(ImmutableAttributeError): 19 | prop.primary_key = None 20 | with pytest.raises(ImmutableAttributeError): 21 | prop.read_only = "DON'T CHANGE ME" 22 | 23 | # Can change these b/c at this point they're still None 24 | prop.name = 'fred' 25 | prop.default = 5 26 | # Type checking is still performed 27 | with pytest.raises(ValueError): 28 | prop.obj = 5 29 | prop.obj = Node('x') 30 | 31 | with pytest.raises(ImmutableAttributeError): 32 | prop.name = 'bob' 33 | with pytest.raises(ImmutableAttributeError): 34 | prop.default = 9 35 | with pytest.raises(ImmutableAttributeError): 36 | prop.obj = Node('y') 37 | 38 | prop = Property(default=5, obj=Node('x')) 39 | with pytest.raises(ImmutableAttributeError): 40 | prop.default = 9 41 | with pytest.raises(ImmutableAttributeError): 42 | prop.obj = Node('y') 43 | 44 | with pytest.raises(ImmutableAttributeError): 45 | del prop.name 46 | with pytest.raises(ImmutableAttributeError): 47 | del prop.type 48 | with pytest.raises(ImmutableAttributeError): 49 | del prop.default 50 | with pytest.raises(ImmutableAttributeError): 51 | del prop.obj 52 | with pytest.raises(ImmutableAttributeError): 53 | del prop.unique 54 | with pytest.raises(ImmutableAttributeError): 55 | del prop.indexed 56 | with pytest.raises(ImmutableAttributeError): 57 | del prop.required 58 | with pytest.raises(ImmutableAttributeError): 59 | del prop.primary_key 60 | with pytest.raises(ImmutableAttributeError): 61 | del prop.read_only 62 | 63 | 64 | def test_node_set_once(): 65 | node = Node('x') 66 | with pytest.raises(ImmutableAttributeError): 67 | node.labels = list('abc') 68 | with pytest.raises(ImmutableAttributeError): 69 | node.type = 'y' 70 | with pytest.raises(ImmutableAttributeError): 71 | del node.labels 72 | with pytest.raises(ImmutableAttributeError): 73 | del node.type 74 | 75 | 76 | def test_relationship_set_once(): 77 | rel = Relationship(None) 78 | rel.type = 'X' 79 | rel.start_node = Node('x') 80 | rel.end_node = Node('y') 81 | with pytest.raises(ImmutableAttributeError): 82 | rel.directed = False 83 | with pytest.raises(ImmutableAttributeError): 84 | rel.type = 'Y' 85 | with pytest.raises(ImmutableAttributeError): 86 | rel.start_node = Node('a') 87 | with pytest.raises(ImmutableAttributeError): 88 | rel.end_node = Node('b') 89 | 90 | rel = Relationship('X') 91 | with pytest.raises(ImmutableAttributeError): 92 | rel.type = 'Y' 93 | 94 | rel = Relationship('X', Node('a')) 95 | with pytest.raises(ImmutableAttributeError): 96 | rel.start_node = Node('a') 97 | 98 | rel = Relationship('X', Node('a'), Node('b')) 99 | with pytest.raises(ImmutableAttributeError): 100 | rel.end_node = Node('a') 101 | 102 | with pytest.raises(ImmutableAttributeError): 103 | del rel.type 104 | with pytest.raises(ImmutableAttributeError): 105 | del rel.directed 106 | with pytest.raises(ImmutableAttributeError): 107 | del rel.start_node 108 | with pytest.raises(ImmutableAttributeError): 109 | del rel.end_node 110 | --------------------------------------------------------------------------------