├── .gitignore ├── .travis.yml ├── AUTHORS ├── CHANGELOG ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── benchmarks ├── __init__.py ├── benchmark.py ├── benchmarks.py ├── run.py └── utils.py ├── docs ├── Makefile └── source │ ├── benchmarks.rst │ ├── conf.py │ ├── core │ ├── database.rst │ ├── index.rst │ ├── node_data.rst │ ├── positioning.rst │ ├── properties.rst │ ├── query.rst │ └── tree.rst │ ├── db_model.rst │ ├── index.rst │ ├── installation.rst │ ├── node.rst │ ├── quickstart.rst │ ├── transaction.rst │ ├── tree.rst │ └── user_guide.rst ├── libtree ├── __init__.py ├── core │ ├── __init__.py │ ├── database.py │ ├── node_data.py │ ├── positioning.py │ ├── properties.py │ ├── query.py │ └── tree.py ├── exceptions.py ├── node.py ├── sql │ ├── schema.sql │ └── triggers.sql ├── transactions.py ├── tree.py └── utils.py ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── core │ ├── test_database.py │ ├── test_node_data.py │ ├── test_positioning.py │ ├── test_properties.py │ ├── test_query.py │ └── test_tree.py ├── test_node.py ├── test_transactions.py ├── test_tree.py └── test_utils.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | */.cache 3 | */.coverage 4 | */.DS_Store 5 | */__pycache__ 6 | junit/ 7 | .cache/ 8 | .coverage 9 | .eggs 10 | .DS_Store 11 | .tox 12 | __pycache__ 13 | build 14 | docs/build 15 | libtree.egg-info 16 | dist/ 17 | junit-*.xml 18 | coverage.xml 19 | .coverage 20 | dump.rdb 21 | libtree/config.py 22 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.3" 5 | - "3.4" 6 | - "3.5" 7 | - "pypy" 8 | - "pypy3" 9 | 10 | # Travis doesn't support PostgreSQL 9.5 yet: 11 | #services: 12 | # - postgresql 13 | #addons: 14 | # postgresql: "9.5" 15 | 16 | # Custom PostgreSQL 9.5: 17 | sudo: required 18 | env: 19 | - PGPORT=5433 PGHOST=localhost 20 | addons: 21 | apt: 22 | sources: 23 | - precise-pgdg-9.5 24 | packages: 25 | - postgresql-9.5 26 | - postgresql-contrib-9.5 27 | postgresql: 9.5 28 | 29 | before_script: 30 | - sudo cp /etc/postgresql/9.4/main/pg_hba.conf /etc/postgresql/9.5/main/pg_hba.conf 31 | - sudo /etc/init.d/postgresql restart 32 | - psql -c 'create database test_libtree;' -U postgres 33 | install: 34 | - pip install . 35 | - pip install coveralls pytest-cov 36 | script: 37 | - make unit 38 | after_success: 39 | - coveralls 40 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | libtree is written and maintained by Fabian Kochem. 2 | 3 | Contributors: 4 | 5 | James Hutchby (GH: jameshy) 6 | Richard Klees (GH: klees / lechimp-p) 7 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 6.0.1 2 | ----- 3 | * [FIX] Check whether libtree is installed when calling install() 4 | 5 | 6.0.0 6 | ----- 7 | * [BREAKING] Node.ancestors and core.query.get_ancestors() now return 8 | their results ordered bottom-up. 9 | 10 | 5.2.0 11 | ----- 12 | * [FEATURE] Add Node.recursive_properties, which contains the recursively 13 | merged property dictionary of all ancestors and itself. 14 | 15 | 5.1.0 16 | ----- 17 | * [FEATURE] Add Node.update_properties() 18 | 19 | 5.0.1 20 | ----- 21 | * [FIX] Fix bug where one couldn't switch from read-only to read-write 22 | in the same connection. 23 | 24 | 5.0.0 25 | ----- 26 | * [BREAKING] Remove Transaction class and replace it by ReadOnlyTransaction 27 | and ReadWriteTransaction. To migrate your code please pass write=True 28 | when creating a transaction via context manager. Example: 29 | with tree(write=True) as transaction: ... 30 | 31 | 4.0.2 32 | ----- 33 | * [FIX] Subclasses of type 'str' are now accepted as IDs 34 | 35 | 4.0.1 36 | ----- 37 | * [FEATURE] Custom UUID4s can be passed to Node.insert_child() 38 | 39 | 4.0.0 40 | ----- 41 | * [BREAKING] Require PostgreSQL 9.5 42 | * [BREAKING] Replace integer IDs with UUID4 strings 43 | 44 | 3.0.0 45 | ----- 46 | * [BREAKING] Native Python exceptions have been replaced by custom ones 47 | * [FEATURE] Add 'has_children' property for Node objects 48 | * [FIX] Accessing the 'parent' property of a root object now returns None 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Fabian Kochem 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include benchmarks * 2 | recursive-include libtree/sql * 3 | global-exclude __pycache__ 4 | global-exclude *.pyc 5 | global-exclude config.py 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test unit acceptance style style-verbose docs clean 2 | 3 | test: 4 | tox 5 | 6 | unit: 7 | py.test -v --cov-report term-missing --cov libtree tests 8 | 9 | acceptance: 10 | behave tests/features 11 | 12 | style: 13 | flake8 --show-source --ignore=E731 libtree 14 | flake8 --show-source --ignore=E731,F811,F821 tests 15 | 16 | style-verbose: 17 | flake8 -v --show-source --ignore=E731 libtree 18 | flake8 -v --show-source --ignore=E731,F811,F821 tests 19 | 20 | docs: 21 | make -C docs html 22 | 23 | clean: 24 | find . -name '*.pyc' -exec rm -f {} \; 25 | find libtree -name "__pycache__" | xargs rm -rf 26 | find tests -name "__pycache__" | xargs rm -rf 27 | make -C docs clean 28 | rm -f coverage.xml 29 | rm -rf *.egg-info 30 | rm -rf .cache/ 31 | rm -rf .eggs/ 32 | rm -rf .tox/ 33 | rm -rf build/ 34 | rm -rf docs/build/ 35 | rm -rf dist/ 36 | rm -rf junit/ 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | libtree 2 | ======= 3 | [![Build Status](https://travis-ci.org/vortec/libtree.svg?branch=master)](https://travis-ci.org/vortec/libtree) 4 | [![Coverage Status](https://coveralls.io/repos/vortec/libtree/badge.svg?branch=master&service=github)](https://coveralls.io/github/vortec/libtree?branch=master) 5 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/vortec/libtree/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/vortec/libtree/?branch=master) 6 | [![Documentation Status](https://readthedocs.org/projects/libtree/badge/?version=latest)](https://libtree.readthedocs.org/en/latest/?badge=latest) 7 | 8 | 9 | **libtree** is a Python library which assists you in dealing with **large, 10 | hierarchical data sets**. It runs on top of **PostgreSQL 9.5** and is 11 | compatible with **all major Python interpreters** (2.7, 3.3-3.5, PyPy2 12 | and PyPy3). 13 | 14 | Why use **libtree**? Because... 15 | 16 | - the usage is **super simple** 17 | - it scales up to **billions of nodes** 18 | - the reads and writes are **blazingly fast** 19 | - it supports **attribute inheritance** 20 | 21 | 22 | But there's even more, **libtree**... 23 | 24 | - offers **thread-safety** by working inside transactions 25 | - enforces **integrity** by moving tree logic to inside the database 26 | - provides a **convenient** high level API and **fast** low level functions 27 | - core is **fully integration tested**, the testsuite covers **>90%** of the code 28 | 29 | 30 | Installation 31 | ============ 32 | Install **libtree** directly via ``pip``: 33 | 34 | ```bash 35 | $ pip install libtree 36 | ``` 37 | 38 | Upgrading 39 | ========= 40 | We respect [semantic versioning](http://semver.org/). Please read the 41 | [CHANGELOG](https://github.com/conceptsandtraining/libtree/blob/master/CHANGELOG) 42 | to find out which breaking changes we made! 43 | 44 | 45 | Quickstart 46 | ========== 47 | Start the interactive Python interpreter of your choice to start working with 48 | **libtree**: 49 | 50 | ```python 51 | # Imports 52 | from libtree import Tree 53 | import psycopg2 54 | 55 | # Connect to PostgreSQL 56 | connection = psycopg2.connect("dbname=test_tree user=vortec") 57 | tree = Tree(connection) 58 | 59 | # Start working with libtree inside a database transaction 60 | with tree(write=True) as transaction: 61 | 62 | # Create tables 63 | transaction.install() 64 | 65 | # Create nodes 66 | root = transaction.insert_root_node() 67 | binx = root.insert_child({'title': 'Binary folder'}) 68 | bash = binx.insert_child({'title': 'Bash executable', 'chmod': 755}) 69 | etc = root.insert_child({'title': 'Config folder'}) 70 | hosts = etc.insert_child({'title': 'Hosts file'}) 71 | passwd = etc.insert_child({'title': 'Password file', 'chmod': 644}) 72 | 73 | # Direct attribute access 74 | root.children # => binx, etc 75 | len(root) # => 2 76 | binx.parent # => root 77 | bash.ancestors # => binx, root 78 | root.descendants # => binx, bash, etc, hosts, passwd 79 | 80 | # Query by property 81 | transaction.get_nodes_by_property_key('chmod') # bash, passwd 82 | transaction.get_nodes_by_property_dict({'chmod': 644}) # passwd 83 | 84 | # Move bash node into etc node 85 | bash.move(etc) 86 | etc.children # => hosts, passwd, bash 87 | bash.set_position(1) 88 | etc.children # => hosts, bash, passwd 89 | 90 | # Print entire tree 91 | transaction.print_tree() 92 | # Output: 93 | # 94 | # 95 | # 96 | # 97 | # 98 | # 99 | ``` 100 | 101 | 102 | Documentation 103 | ============= 104 | The full documentation including API reference and database model 105 | description can be found at 106 | **[ReadTheDocs.org](https://libtree.readthedocs.org/en/latest/)**. 107 | 108 | 109 | Authors 110 | ======= 111 | **libtree** is written and maintained by Fabian Kochem. 112 | -------------------------------------------------------------------------------- /benchmarks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vortec/libtree/28033e43f30a7f1f223b0863886177376e7386e7/benchmarks/__init__.py -------------------------------------------------------------------------------- /benchmarks/benchmark.py: -------------------------------------------------------------------------------- 1 | import time 2 | import sys 3 | 4 | if sys.platform == "win32": 5 | default_timer = time.clock 6 | else: 7 | default_timer = time.time 8 | 9 | 10 | class Benchmark(): 11 | 12 | def __init__(self, func, name, repeat=3): 13 | self.func = func 14 | self.repeat = repeat 15 | self.name = name 16 | 17 | def __str__(self): 18 | return "".format(self.name) 19 | 20 | def run(self, transaction): 21 | self.results = [] 22 | for x in range(self.repeat): 23 | start = default_timer() 24 | self.func() 25 | end = default_timer() 26 | elapsed = end - start 27 | self.results.append(elapsed) 28 | transaction.rollback() 29 | return min(self.results) 30 | -------------------------------------------------------------------------------- /benchmarks/benchmarks.py: -------------------------------------------------------------------------------- 1 | from benchmark import Benchmark 2 | import libtree 3 | 4 | 5 | def _get_node_by_title(transaction, title): 6 | nodes = transaction.get_nodes_by_property_value("title", title) 7 | return nodes.pop() if nodes else None 8 | 9 | 10 | def change_parent_worst_case(transaction): 11 | node = _get_node_by_title(transaction, "0x0") 12 | new_parent = _get_node_by_title(transaction, "0x1") 13 | return lambda: node.move(new_parent) 14 | 15 | 16 | def change_parent_best_case(transaction): 17 | node = _get_node_by_title(transaction, "0x0x0x0x0x0x0x1") 18 | new_parent = _get_node_by_title(transaction, "0x0x0x0x0x0") 19 | return lambda: node.move(new_parent) 20 | 21 | 22 | def delete_node_best_case(transaction): 23 | node = _get_node_by_title(transaction, "0x0x0x0x0x0x0x0") 24 | return lambda: node.delete() 25 | 26 | 27 | def delete_node_worst_case(transaction): 28 | node = _get_node_by_title(transaction, "0x0") 29 | return lambda: node.delete() 30 | 31 | 32 | def iterate_get_ancestors(transaction): 33 | node = _get_node_by_title(transaction, "0x0x0x0x0x0x0x0") 34 | def method(): 35 | nonlocal node 36 | for a in node.ancestors: 37 | pass 38 | return method 39 | 40 | 41 | def create_benchmarks(transaction, config): 42 | 43 | test_node_id = config['test_node_id'] 44 | test_node = transaction.get_node(test_node_id) 45 | 46 | bs = [ 47 | Benchmark( 48 | lambda: transaction.get_tree_size(), 49 | "get_tree_size" 50 | ), 51 | Benchmark( 52 | lambda: transaction.get_root_node(), 53 | "get_root_node" 54 | ), 55 | Benchmark( 56 | lambda: transaction.get_node(test_node_id), 57 | "get_node" 58 | ), 59 | Benchmark( 60 | lambda: test_node.children, 61 | "get_children" 62 | ), 63 | Benchmark( 64 | lambda: test_node.ancestors, 65 | "get_ancestors" 66 | ), 67 | Benchmark( 68 | lambda: test_node.descendants, 69 | "get_descendants" 70 | ), 71 | Benchmark( 72 | lambda: test_node.insert_child(), 73 | "insert_child" 74 | ), 75 | Benchmark( 76 | delete_node_worst_case(transaction), 77 | "delete_node_worst_case" 78 | ), 79 | Benchmark( 80 | delete_node_best_case(transaction), 81 | "delete_node_best_case" 82 | ), 83 | Benchmark( 84 | change_parent_worst_case(transaction), 85 | "change_parent_worst_case" 86 | ), 87 | Benchmark( 88 | change_parent_best_case(transaction), 89 | "change_parent_best_case" 90 | ), 91 | Benchmark( 92 | iterate_get_ancestors(transaction), 93 | "iterate_get_ancestors" 94 | ), 95 | ] 96 | return bs 97 | -------------------------------------------------------------------------------- /benchmarks/run.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import argparse 3 | import psycopg2 4 | from libtree import Tree 5 | from utils import postgres_create_db, postgres_analyze_db, generate_tree, format_duration 6 | from benchmarks import create_benchmarks 7 | 8 | DBNAME = 'benchmark_libtree' 9 | 10 | config = { 11 | 'benchmark_db': 'dbname={} user=postgres'.format(DBNAME), 12 | 'postgres_db': 'dbname=postgres user=postgres', 13 | 'levels': 7, 14 | 'per_level': 7, 15 | 'test_node_id': 505, 16 | 'generate_tree': True, 17 | 'filter_benchmarks': None 18 | } 19 | 20 | 21 | def run(): 22 | if config['generate_tree']: 23 | # drop existing database and recreate 24 | postgres_create_db(config['postgres_db'], DBNAME) 25 | 26 | connection = psycopg2.connect(config['benchmark_db']) 27 | tree = Tree(connection) 28 | 29 | with tree() as transaction: 30 | transaction.install() 31 | # create tree with test data 32 | generate_tree(transaction, config['levels'], config['per_level']) 33 | 34 | connection = psycopg2.connect(config['benchmark_db']) 35 | tree = Tree(connection) 36 | 37 | with tree() as transaction: 38 | postgres_analyze_db(transaction.cursor) 39 | 40 | # build a list of benchmarks to run 41 | benchmarks = create_benchmarks(transaction, config) 42 | benchmarks_to_run = [] 43 | filter_benchmarks = config['filter_benchmarks'] 44 | 45 | for b in benchmarks: 46 | if not filter_benchmarks or filter_benchmarks in b.name: 47 | benchmarks_to_run.append(b) 48 | 49 | print() 50 | 51 | if len(benchmarks_to_run): 52 | print("Running benchmarks..") 53 | 54 | for benchmark in benchmarks_to_run: 55 | print(benchmark.name.ljust(30), end="") 56 | sys.stdout.flush() 57 | duration = benchmark.run(transaction) 58 | print(format_duration(duration)) 59 | 60 | else: 61 | print("No benchmarks to run") 62 | 63 | 64 | if __name__ == "__main__": 65 | 66 | parser = argparse.ArgumentParser() 67 | parser.add_argument("-s", "--skip-tree-generation", 68 | help="Skip the tree generation", 69 | action="store_true") 70 | parser.add_argument("-f", "--filter-benchmarks", 71 | help="Filter benchmarks by name") 72 | 73 | args = parser.parse_args() 74 | 75 | if args.skip_tree_generation: 76 | config['generate_tree'] = False 77 | if args.filter_benchmarks: 78 | config['filter_benchmarks'] = args.filter_benchmarks 79 | 80 | run() 81 | -------------------------------------------------------------------------------- /benchmarks/utils.py: -------------------------------------------------------------------------------- 1 | import psycopg2 2 | import math 3 | import libtree 4 | 5 | CURSOR_UP_ONE = '\x1b[1A' 6 | ERASE_LINE = '\x1b[2K' 7 | REFRESH_TERMINAL_DELAY = 30 8 | 9 | 10 | def postgres_create_db(dsn, dbname): 11 | conn = psycopg2.connect(dsn) 12 | conn.set_isolation_level(0) 13 | cur = conn.cursor() 14 | try: 15 | cur.execute("DROP DATABASE {}".format(dbname)) 16 | print("dropped database {}".format(dbname)) 17 | except psycopg2.ProgrammingError: 18 | # database does not exist 19 | pass 20 | cur.execute("CREATE DATABASE {}".format(dbname)) 21 | print("created database {}".format(dbname)) 22 | 23 | 24 | def postgres_analyze_db(cur): 25 | cur.execute('ANALYZE VERBOSE') 26 | 27 | 28 | def calculate_tree_size(levels, per_level): 29 | # time.sleep(0.5) 30 | if per_level == 1: 31 | return levels 32 | return int(((1 - per_level ** (levels + 1)) / (1 - per_level)) - 1) 33 | 34 | 35 | def generate_tree(transaction, levels, per_level): 36 | def insert_node(*args, **kwargs): 37 | # wrap libtree.insert_node so we can print the current progress 38 | nonlocal n_inserted, expected_nodes 39 | node = libtree.core.insert_node(*args, **kwargs) 40 | n_inserted += 1 41 | 42 | # only print progress at certain points 43 | if not n_inserted % REFRESH_TERMINAL_DELAY or n_inserted == expected_nodes: 44 | if n_inserted > REFRESH_TERMINAL_DELAY: 45 | print(CURSOR_UP_ONE + ERASE_LINE, end="") 46 | print(n_inserted) 47 | 48 | return node 49 | 50 | def insert_children(parent, label, current_depth=1): 51 | label.append("x") 52 | for x in range(per_level): 53 | label2 = label.copy() 54 | label2.append(x) 55 | title = "".join(map(str, label2)) 56 | properties = {"title": title} 57 | node = insert_node( 58 | transaction.cursor, parent, properties, position=x, auto_position=False) 59 | if current_depth < levels: 60 | insert_children(node, label2, current_depth + 1) 61 | 62 | n_inserted = 0 63 | expected_nodes = calculate_tree_size(levels, per_level) 64 | print("generating tree with {} nodes..".format(expected_nodes)) 65 | root = insert_node(transaction.cursor, None, properties={"title": "0"}) 66 | insert_children(root, [0]) 67 | print("done") 68 | 69 | 70 | def format_duration(seconds): 71 | """ 72 | pretty-print a duration. 73 | 74 | :param float seconds: duration to be formatted. 75 | """ 76 | units = ["s", "ms", 'us', "ns"] 77 | scaling = [1, 1e3, 1e6, 1e9] 78 | if seconds > 0.0 and seconds < 1000.0: 79 | order = min(-int(math.floor(math.log10(seconds)) // 3), 3) 80 | elif seconds >= 1000.0: 81 | order = 0 82 | else: 83 | order = 3 84 | return "{:.2f}{}".format(seconds * scaling[order], units[order]) 85 | -------------------------------------------------------------------------------- /docs/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 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/libtree.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/libtree.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/libtree" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/libtree" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/source/benchmarks.rst: -------------------------------------------------------------------------------- 1 | .. _benchmarks: 2 | 3 | Benchmarks 4 | ========== 5 | 6 | Available soon. 7 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # libtree documentation build configuration file, created by 5 | # sphinx-quickstart on Tue Aug 11 09:44:41 2015. 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 | import sys 17 | import os 18 | import shlex 19 | 20 | # If extensions (or modules to document with autodoc) are in another directory, 21 | # add these directories to sys.path here. If the directory is relative to the 22 | # documentation root, use os.path.abspath to make it absolute, like shown here. 23 | #sys.path.insert(0, os.path.abspath('.')) 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 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.todo', 36 | ] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ['_templates'] 40 | 41 | # The suffix(es) of source filenames. 42 | # You can specify multiple suffix as a list of string: 43 | # source_suffix = ['.rst', '.md'] 44 | source_suffix = '.rst' 45 | 46 | # The encoding of source files. 47 | #source_encoding = 'utf-8-sig' 48 | 49 | # The master toctree document. 50 | master_doc = 'index' 51 | 52 | # General information about the project. 53 | project = 'libtree' 54 | copyright = '2016, Fabian Kochem' 55 | author = 'Fabian Kochem' 56 | 57 | # The version info for the project you're documenting, acts as replacement for 58 | # |version| and |release|, also used in various other places throughout the 59 | # built documents. 60 | # 61 | import pkg_resources 62 | try: 63 | release = pkg_resources.get_distribution('libtree').version 64 | except pkg_resources.DistributionNotFound: 65 | print 'To build the documentation, The distribution information of libtree' 66 | print 'Has to be available. Either install the package into your' 67 | print 'development environment or run "setup.py develop" to setup the' 68 | print 'metadata. A virtualenv is recommended!' 69 | sys.exit(1) 70 | del pkg_resources 71 | 72 | if 'dev' in release: 73 | release = release.split('dev')[0] + 'dev' 74 | version = '.'.join(release.split('.')[:2]) 75 | 76 | # The language for content autogenerated by Sphinx. Refer to documentation 77 | # for a list of supported languages. 78 | # 79 | # This is also used if you do content translation via gettext catalogs. 80 | # Usually you set "language" from the command line for these cases. 81 | language = None 82 | 83 | # There are two options for replacing |today|: either, you set today to some 84 | # non-false value, then it is used: 85 | #today = '' 86 | # Else, today_fmt is used as the format for a strftime call. 87 | #today_fmt = '%B %d, %Y' 88 | 89 | # List of patterns, relative to source directory, that match files and 90 | # directories to ignore when looking for source files. 91 | exclude_patterns = ['_build'] 92 | 93 | # The reST default role (used for this markup: `text`) to use for all 94 | # documents. 95 | #default_role = None 96 | 97 | # If true, '()' will be appended to :func: etc. cross-reference text. 98 | #add_function_parentheses = True 99 | 100 | # If true, the current module name will be prepended to all description 101 | # unit titles (such as .. function::). 102 | add_module_names = False 103 | 104 | # If true, sectionauthor and moduleauthor directives will be shown in the 105 | # output. They are ignored by default. 106 | #show_authors = False 107 | 108 | # The name of the Pygments (syntax highlighting) style to use. 109 | pygments_style = 'sphinx' 110 | 111 | # A list of ignored prefixes for module index sorting. 112 | #modindex_common_prefix = [] 113 | 114 | # If true, keep warnings as "system message" paragraphs in the built documents. 115 | #keep_warnings = False 116 | 117 | # If true, `todo` and `todoList` produce output, else they produce nothing. 118 | todo_include_todos = True 119 | 120 | 121 | # -- Options for HTML output ---------------------------------------------- 122 | 123 | # The theme to use for HTML and HTML Help pages. See the documentation for 124 | # a list of builtin themes. 125 | html_theme = 'sphinx_rtd_theme' 126 | 127 | # Theme options are theme-specific and customize the look and feel of a theme 128 | # further. For a list of options available for each theme, see the 129 | # documentation. 130 | #html_theme_options = {} 131 | 132 | # Add any paths that contain custom themes here, relative to this directory. 133 | #html_theme_path = [] 134 | 135 | # The name for this set of Sphinx documents. If None, it defaults to 136 | # " v documentation". 137 | #html_title = None 138 | 139 | # A shorter title for the navigation bar. Default is the same as html_title. 140 | #html_short_title = None 141 | 142 | # The name of an image file (relative to this directory) to place at the top 143 | # of the sidebar. 144 | #html_logo = None 145 | 146 | # The name of an image file (within the static path) to use as favicon of the 147 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 148 | # pixels large. 149 | #html_favicon = None 150 | 151 | # Add any paths that contain custom static files (such as style sheets) here, 152 | # relative to this directory. They are copied after the builtin static files, 153 | # so a file named "default.css" will overwrite the builtin "default.css". 154 | html_static_path = ['_static'] 155 | 156 | # Add any extra paths that contain custom files (such as robots.txt or 157 | # .htaccess) here, relative to this directory. These files are copied 158 | # directly to the root of the documentation. 159 | #html_extra_path = [] 160 | 161 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 162 | # using the given strftime format. 163 | #html_last_updated_fmt = '%b %d, %Y' 164 | 165 | # If true, SmartyPants will be used to convert quotes and dashes to 166 | # typographically correct entities. 167 | #html_use_smartypants = True 168 | 169 | # Custom sidebar templates, maps document names to template names. 170 | html_sidebars = { 171 | '**': ['globaltoc.html', 'localtoc.html', 'relations.html', 'searchbox.html'] 172 | } 173 | 174 | # Additional templates that should be rendered to pages, maps page names to 175 | # template names. 176 | #html_additional_pages = {} 177 | 178 | # If false, no module index is generated. 179 | html_domain_indices = False 180 | 181 | # If false, no index is generated. 182 | #html_use_index = True 183 | 184 | # If true, the index is split into individual pages for each letter. 185 | #html_split_index = False 186 | 187 | # If true, links to the reST sources are added to the pages. 188 | #html_show_sourcelink = True 189 | 190 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 191 | html_show_sphinx = False 192 | 193 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 194 | html_show_copyright = False 195 | 196 | # If true, an OpenSearch description file will be output, and all pages will 197 | # contain a tag referring to it. The value of this option must be the 198 | # base URL from which the finished HTML is served. 199 | #html_use_opensearch = '' 200 | 201 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 202 | #html_file_suffix = None 203 | 204 | # Language to be used for generating the HTML full-text search index. 205 | # Sphinx supports the following languages: 206 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 207 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' 208 | #html_search_language = 'en' 209 | 210 | # A dictionary with options for the search language support, empty by default. 211 | # Now only 'ja' uses this config value 212 | #html_search_options = {'type': 'default'} 213 | 214 | # The name of a javascript file (relative to the configuration directory) that 215 | # implements a search results scorer. If empty, the default will be used. 216 | #html_search_scorer = 'scorer.js' 217 | 218 | # Output file base name for HTML help builder. 219 | htmlhelp_basename = 'libtreedoc' 220 | 221 | # -- Options for LaTeX output --------------------------------------------- 222 | 223 | latex_elements = { 224 | # The paper size ('letterpaper' or 'a4paper'). 225 | #'papersize': 'letterpaper', 226 | 227 | # The font size ('10pt', '11pt' or '12pt'). 228 | #'pointsize': '10pt', 229 | 230 | # Additional stuff for the LaTeX preamble. 231 | #'preamble': '', 232 | 233 | # Latex figure (float) alignment 234 | #'figure_align': 'htbp', 235 | } 236 | 237 | # Grouping the document tree into LaTeX files. List of tuples 238 | # (source start file, target name, title, 239 | # author, documentclass [howto, manual, or own class]). 240 | latex_documents = [ 241 | (master_doc, 'libtree.tex', 'libtree Documentation', 242 | 'Fabian Kochem', 'manual'), 243 | ] 244 | 245 | # The name of an image file (relative to this directory) to place at the top of 246 | # the title page. 247 | #latex_logo = None 248 | 249 | # For "manual" documents, if this is true, then toplevel headings are parts, 250 | # not chapters. 251 | #latex_use_parts = False 252 | 253 | # If true, show page references after internal links. 254 | #latex_show_pagerefs = False 255 | 256 | # If true, show URL addresses after external links. 257 | #latex_show_urls = False 258 | 259 | # Documents to append as an appendix to all manuals. 260 | #latex_appendices = [] 261 | 262 | # If false, no module index is generated. 263 | #latex_domain_indices = True 264 | 265 | 266 | # -- Options for manual page output --------------------------------------- 267 | 268 | # One entry per manual page. List of tuples 269 | # (source start file, name, description, authors, manual section). 270 | man_pages = [ 271 | (master_doc, 'libtree', 'libtree Documentation', 272 | [author], 1) 273 | ] 274 | 275 | # If true, show URL addresses after external links. 276 | #man_show_urls = False 277 | 278 | 279 | # -- Options for Texinfo output ------------------------------------------- 280 | 281 | # Grouping the document tree into Texinfo files. List of tuples 282 | # (source start file, target name, title, author, 283 | # dir menu entry, description, category) 284 | texinfo_documents = [ 285 | (master_doc, 'libtree', 'libtree Documentation', 286 | author, 'libtree', 'One line description of project.', 287 | 'Miscellaneous'), 288 | ] 289 | 290 | # Documents to append as an appendix to all manuals. 291 | #texinfo_appendices = [] 292 | 293 | # If false, no module index is generated. 294 | #texinfo_domain_indices = True 295 | 296 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 297 | #texinfo_show_urls = 'footnote' 298 | 299 | # If true, do not generate a @detailmenu in the "Top" node's menu. 300 | #texinfo_no_detailmenu = False 301 | -------------------------------------------------------------------------------- /docs/source/core/database.rst: -------------------------------------------------------------------------------- 1 | .. _corefuncs-database: 2 | 3 | Database functions 4 | ================== 5 | 6 | .. automodule:: libtree.core.database 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/source/core/index.rst: -------------------------------------------------------------------------------- 1 | .. _core: 2 | 3 | Core functions 4 | ============== 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | database 10 | node_data 11 | positioning 12 | properties 13 | query 14 | tree 15 | -------------------------------------------------------------------------------- /docs/source/core/node_data.rst: -------------------------------------------------------------------------------- 1 | .. _core-node_data: 2 | 3 | NodeData class 4 | ============== 5 | 6 | .. automodule:: libtree.core.node_data 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/source/core/positioning.rst: -------------------------------------------------------------------------------- 1 | .. _core-positioning: 2 | 3 | Positioning functions 4 | ===================== 5 | 6 | .. automodule:: libtree.core.positioning 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/source/core/properties.rst: -------------------------------------------------------------------------------- 1 | .. _core-properties: 2 | 3 | Property functions 4 | ================== 5 | 6 | .. automodule:: libtree.core.properties 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/source/core/query.rst: -------------------------------------------------------------------------------- 1 | .. _core-query: 2 | 3 | Query functions 4 | =============== 5 | 6 | .. automodule:: libtree.core.query 7 | :members: -------------------------------------------------------------------------------- /docs/source/core/tree.rst: -------------------------------------------------------------------------------- 1 | .. _core-tree: 2 | 3 | Tree functions 4 | ============== 5 | 6 | .. automodule:: libtree.core.tree 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/source/db_model.rst: -------------------------------------------------------------------------------- 1 | .. _db_model: 2 | 3 | Database Model 4 | ============== 5 | `libtree` aims to support billions of nodes while guaranteeing fast 6 | reads and fast writes. Well-known SQL solutions like Adjacency List or 7 | Nested Set have drawbacks which hinder performance in either direction. 8 | A very good model to achieve high performance is called `Closure Table`, 9 | which is explained here. 10 | 11 | 12 | Closure Table 13 | ------------- 14 | In Closure Table, you have two tables. One contains the node metadata, 15 | the other one contains every possible ancestor/descendant combination. 16 | In libtree, here's what they look like:: 17 | 18 | CREATE TABLE nodes 19 | ( 20 | id serial NOT NULL, 21 | parent integer, 22 | "position" smallint DEFAULT NULL, 23 | properties jsonb NOT NULL, 24 | CONSTRAINT "primary" PRIMARY KEY (id) 25 | ) 26 | 27 | This is pretty simple and should be self-explanatory. Note that libtree 28 | uses the Adjacency List-style ``parent`` column, even though it's 29 | possible to drag this information out of the ancestor table (see below). 30 | This is mainly for speed reasons as it avoids a JOIN operation onto a 31 | huge table. 32 | 33 | The more interesting bit is the ancestor table:: 34 | 35 | CREATE TABLE ancestors 36 | ( 37 | node integer NOT NULL, 38 | ancestor integer NOT NULL, 39 | CONSTRAINT idx UNIQUE (node, ancestor) 40 | ) 41 | 42 | In this table, every tree relation is stored. This means not only 43 | child/parent, but also grandparent/grandchild relations. So if A is a 44 | parent of B, and B is a parent of C and C is a parent of D, we need to 45 | store the following relations: 46 | 47 | +------+----------+ 48 | | node | ancestor | 49 | +======+==========+ 50 | | A | B | 51 | +------+----------+ 52 | | A | C | 53 | +------+----------+ 54 | | A | D | 55 | +------+----------+ 56 | | B | C | 57 | +------+----------+ 58 | | B | D | 59 | +------+----------+ 60 | | C   | D | 61 | +------+----------+ 62 | 63 | `(in the real implementation integers are being used)` 64 | 65 | This information enables us to query the tree quickly without any form 66 | of recursion. To get the entire subtree of a node, you'd execute 67 | ``SELECT ancestor FROM ancestors WHERE node='B'``. Likewise, to get all 68 | ancestors of a node, you'd execute ``SELECT node FROM ancestors WHERE 69 | ancestor='D'``. In both queries you can simply JOIN the nodes table to 70 | retrieve the corresponding metadata. In the second query, you might 71 | notice that the output comes in no particular order, because there is no 72 | column to run SORT BY on. This is an implementation detail of libtree in 73 | order to save disk space and might change at a later point. 74 | 75 | Manipulating the tree is somewhat more complex. When inserting a node, 76 | the ancestor information of its parent must be copied and completed. 77 | When deleting a node, all traces of it and its descendants must be 78 | deleted from both tables. When moving a node, first all outdated 79 | ancestor information must be found and deleted. Then the new parents 80 | ancestor information must be copied for the node (and its descendants) 81 | that is being moved and completed. 82 | 83 | There are different ways to implement Closure Table. Some people store 84 | the depth of each ancestor/descendant combination to make sorting 85 | easier, some don't use the Adjacency List-style `parent` column, and 86 | some even save paths of `length zero` to reduce the complexity of some 87 | queries. 88 | 89 | 90 | Indices 91 | ------- 92 | Everything has tradeoffs; libtree trades speed for disk space. This 93 | means its indices are huge. Both columns in the ancestor table are 94 | indexed separately and together, resulting in index sizes that are twice 95 | the size of the actual data. In the nodes table the columns ``id`` and 96 | ``parent`` are indexed, resulting in index sizes that are roughly the 97 | same as the data. 98 | 99 | Maybe it's possible to remove indices, this needs benchmarking. But RAM 100 | and disk space became very cheap and don't really matter these days, 101 | right? ... right? 102 | 103 | 104 | Database Triggers 105 | ----------------- 106 | The ancestor calculation happens automatically inside PostgreSQL using 107 | trigger functions written in PL/pgSQL. This is great because it means 108 | the user doesn't `have` to use libtree to modify the tree. They can use 109 | their own scripts or manually execute queries from CLI. It's possible 110 | to insert nodes, delete nodes or change the parent attribute of nodes - 111 | the integrity stays intact without the user having to do anything. On 112 | the other hand this means that altering the ancestor table will very 113 | likely result in a broken data set (don't do it). 114 | 115 | 116 | Referential Integrity 117 | --------------------- 118 | While one advantage of using Closure Table is the possibility to use the 119 | RDBMSs referential integrity functionality, libtree doesn't use it in 120 | order to get more speed out of inserts and updates. If the integrity 121 | gets broken somehow, it's simple to fix: 122 | 123 | * export nodes table using pgAdmin or similar 124 | * delete both tables 125 | * install libtree again 126 | * import saved nodes table 127 | 128 | 129 | Boundaries 130 | ---------- 131 | The ``id`` column is of type ``serial`` (32bit integer) and can 132 | therefore be as high as 2,147,483,647. When needed, changing it 133 | to ``bigserial`` (64bit integer) is simple but requires more space. 134 | 135 | 136 | Model Comparison 137 | ---------------- 138 | **Closure Table** 139 | 140 | As mentioned before, Closure Table is a great database model to handle 141 | tree-like data. Its advantages are both read and write performance and 142 | also ease of implementation. It's recursion free and allows you to use 143 | referential integrity. The most complex and slowest part is when 144 | changing parents. Its disadvantage is high disk usage. 145 | 146 | 147 | **Adjacency List** 148 | 149 | The naive and most simple model. All queries and writes are very simple 150 | and fast. It also is referential integrity compatible. However, querying 151 | for nodes any deeper than the immediate children is near impossible 152 | without using recursion on the script side or the rather new WITH 153 | RECURSIVE statement. 154 | 155 | **Path Enumeration** 156 | 157 | A very good model if you don't mind `stringly typed 158 | `_ integrity and 159 | tremendous use of string functions in SQL queries. It should be fast for 160 | all types of queries but is not RI-compatible. 161 | 162 | **Nested Sets** 163 | 164 | Compared to the others, it's very complex and although popular, the 165 | worst model in all ways. It's simple to query subtrees, but it's hard 166 | and slow to do anything else. If you want to insert a node at the top, 167 | you must rebalance the entire tree. If you get the balancing wrong, you 168 | have no chance to repair the hierarchy. Furthermore it's not 169 | RI-compatible. 170 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. _index: 2 | 3 | Welcome to libtree's documentation! 4 | =================================== 5 | 6 | **libtree** is a Python library which assists you in dealing with 7 | **large, hierarchical data sets**. It runs on top of **PostgreSQL 9.5** 8 | and is compatible with **all major Python interpreters** (2.7, 3.3-3.5, PyPy2 9 | and PyPy3). 10 | 11 | Why use **libtree**? Because... 12 | 13 | * the usage is **super simple** (see :ref:`quickstart`) 14 | * it scales up to **billions of nodes** (see :ref:`db_model`) 15 | * the reads and writes are **blazingly fast** (:ref:`benchmarks` will be 16 | available soon) 17 | * it supports **attribute inheritance** (see :ref:`core-properties`) 18 | 19 | But there's even more, **libtree**... 20 | 21 | * offers **thread-safety** by working inside transactions 22 | * enforces **integrity** by moving tree logic to inside the database 23 | * provides a **convenient** high level API and **fast** low level functions 24 | * core is **fully integration tested**, the testsuite covers >90% of the code 25 | 26 | 27 | Contents 28 | ======== 29 | 30 | .. toctree:: 31 | :maxdepth: 2 32 | 33 | installation 34 | quickstart 35 | user_guide 36 | tree 37 | transaction 38 | node 39 | core/index 40 | benchmarks 41 | db_model 42 | 43 | * :ref:`genindex` 44 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Install libtree directly via:: 5 | 6 | $ pip install libtree 7 | 8 | in your virtualenv. 9 | -------------------------------------------------------------------------------- /docs/source/node.rst: -------------------------------------------------------------------------------- 1 | .. _node: 2 | 3 | Node object 4 | =========== 5 | 6 | .. automodule:: libtree.node 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. _quickstart: 2 | 3 | Quickstart 4 | ========== 5 | 6 | Install ``libtree`` via ``pip install libtree``. Then start the 7 | interactive Python interpreter of your choice to start working with 8 | ``libtree``:: 9 | 10 | # Imports 11 | from libtree import Tree 12 | import psycopg2 13 | 14 | # Connect to PostgreSQL 15 | connection = psycopg2.connect("dbname=test_tree user=vortec") 16 | tree = Tree(connection) 17 | 18 | # Start working with libtree inside a database transaction 19 | with tree(write=True) as transaction: 20 | 21 | # Create tables 22 | transaction.install() 23 | 24 | # Create nodes 25 | root = transaction.insert_root_node() 26 | binx = root.insert_child({'title': 'Binary folder'}) 27 | bash = binx.insert_child({'title': 'Bash executable', 'chmod': 755}) 28 | etc = root.insert_child({'title': 'Config folder'}) 29 | hosts = etc.insert_child({'title': 'Hosts file'}) 30 | passwd = etc.insert_child({'title': 'Password file', 'chmod': 644}) 31 | 32 | # Direct attribute access 33 | root.children # => binx, etc 34 | len(root) # => 2 35 | binx.parent # => root 36 | bash.ancestors # => binx, root 37 | root.descendants # => binx, bash, etc, hosts, passwd 38 | 39 | # Query by property 40 | transaction.get_nodes_by_property_key('chmod') # bash, passwd 41 | transaction.get_nodes_by_property_dict({'chmod': 644}) # passwd 42 | 43 | # Move bash node into etc node 44 | bash.move(etc) 45 | etc.children # => hosts, passwd, bash 46 | bash.set_position(1) 47 | etc.children # => hosts, bash, passwd 48 | 49 | # Print entire tree 50 | transaction.print_tree() 51 | # Output: 52 | # 53 | # 54 | # 55 | # 56 | # 57 | # 58 | -------------------------------------------------------------------------------- /docs/source/transaction.rst: -------------------------------------------------------------------------------- 1 | .. _transaction: 2 | 3 | Transaction objects 4 | =================== 5 | 6 | .. automodule:: libtree.transactions 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/source/tree.rst: -------------------------------------------------------------------------------- 1 | .. _tree: 2 | 3 | Tree object 4 | =========== 5 | 6 | .. automodule:: libtree.tree 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/source/user_guide.rst: -------------------------------------------------------------------------------- 1 | .. _user_guide: 2 | 3 | User Guide 4 | ========== 5 | 6 | Database Connection 7 | ------------------- 8 | To start working with `libtree`, make sure PostgreSQL 9.5 is running. If 9 | you don't have a database yet, create one now:: 10 | 11 | $ createdb libtree 12 | 13 | Next, start a Python interpreter, import libtree and create a 14 | :ref:`tree` object. To make it connect to PostgreSQL, you must create a 15 | `psycopg2` connection. After that, you can install libtree:: 16 | 17 | $ python 18 | Python 3.5.0 (default, Oct 12 2015, 13:41:59) 19 | >>> from libtree import Tree 20 | >>> import psycopg2 21 | >>> connection = psycopg2.connect("dbname=test_tree user=vortec") 22 | >>> transaction = Tree(connection).make_transaction(write=True) 23 | >>> transaction.install() 24 | >>> transaction.commit() 25 | 26 | The ``transaction`` objects represent a database transaction and must be 27 | passed to every function whenever you want to query or modify the tree. 28 | Running ``install()`` creates the SQL tables and must only be executed 29 | if you haven't done so before. Executing ``commit()`` writes the changes 30 | you made to the database. If you want to discard the changes, run 31 | ``transaction.rollback()``. 32 | 33 | For more convenience, you can use the auto-committing context manager:: 34 | 35 | >>> tree = Tree(connection) 36 | >>> with tree(write=True) as transaction: 37 | ... transaction.install() 38 | 39 | When the context manager leaves it will commit the transaction to the 40 | database. If an exception occurs, it will rollback automatically. 41 | 42 | If you want to modify the database, you must pass ``write=True`` to the 43 | context manager. The default behaviour is read-only. 44 | 45 | Modify the tree 46 | --------------- 47 | Now, you can create some nodes:: 48 | 49 | >>> html = transaction.insert_root_node() 50 | >>> title = html.insert_child({'title': 'title', 'content': 'libtree'}) 51 | >>> head = html.insert_child({'title': 'head'}) 52 | >>> body = html.insert_child({'title': 'body'}) 53 | >>> h2 = body.insert_child({'title': 'h2', 'content': 'to libtree'}) 54 | >>> transaction.commit() 55 | 56 | This should render as a nice, DOM-like tree:: 57 | 58 | >>> transaction.print_tree() 59 | 60 | 61 | 62 | 63 | 64 | 65 | But do you spot the mistake? In HTML, a ```` tag goes beneath the 66 | ``<head>`` tag, so let's move it:: 67 | 68 | >>> title.move(head) 69 | >>> transaction.print_tree() 70 | <NodeData id=..> 71 | <NodeData id=.., title='head'> 72 | <NodeData id=.., title='title'> 73 | <NodeData id=.., title='body'> 74 | <NodeData id=.., title='h2'> 75 | 76 | And you also forgot the ``<h1>`` node, let's insert it before ``<h2>``:: 77 | 78 | >>> body.insert_child({'title': 'h1', 'content': 'Welcome'}, position=0) 79 | <Node id=.., title='h1'> 80 | >>> transaction.print_tree() 81 | <NodeData id=..> 82 | <NodeData id=.., title='head'> 83 | <NodeData id=.., title='title'> 84 | <NodeData id=.., title='body'> 85 | <NodeData id=.., title='h1'> 86 | <NodeData id=.., title='h2'> 87 | 88 | Since you know the ID, you can easily delete nodes without a ``Node`` 89 | object:: 90 | 91 | >>> h2.delete() 92 | >>> transaction.print_tree() 93 | <NodeData id=..> 94 | <NodeData id=.., title='head'> 95 | <NodeData id=.., title='title'> 96 | <NodeData id=.., title='body'> 97 | <NodeData id=.., title='h1'> 98 | >>> transaction.commit() 99 | 100 | Query the tree 101 | -------------- 102 | If you want to get a ``Node`` object, you can easily get one by querying 103 | for the ID:: 104 | 105 | >>> title = transaction.get_node('1afce8e3-975a-4daa-93e7-88d879c05224') 106 | >>> title.properties 107 | {'content': 'libtree', 'title': 'title'} 108 | 109 | You can get the immediate children of a node:: 110 | 111 | >>> html.children 112 | [<Node id=.., title='head'>, <Node id=.. 113 | 114 | You can get all nodes that have a certain property key set: 115 | 116 | >>> transaction.get_nodes_by_property_key('content') 117 | {<Node id=.., title='h1'>, <Node id=.., title='title'>} 118 | 119 | Or ask for nodes that have a certain property value set:: 120 | 121 | >>> transaction.get_nodes_by_property_value('content', 'Welcome') 122 | {<Node id=.., title='h1'>} 123 | 124 | If you have a node, you can output the path from the root node to it 125 | too:: 126 | 127 | >>> h1.ancestors 128 | [<Node id=..>, <Node id=.., title='body'>] 129 | -------------------------------------------------------------------------------- /libtree/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | from libtree import core # noqa 5 | try: 6 | import utils # noqa 7 | except ImportError: 8 | import libtree.utils # noqa 9 | from libtree import utils # noqa 10 | from libtree.node import Node # noqa 11 | from libtree.transactions import ReadOnlyTransaction, ReadWriteTransaction # noqa 12 | from libtree.tree import Tree # noqa 13 | -------------------------------------------------------------------------------- /libtree/core/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | from libtree.core.database import * # noqa 5 | from libtree.core.node_data import * # noqa 6 | from libtree.core.properties import * # noqa 7 | from libtree.core.positioning import * # noqa 8 | from libtree.core.query import * # noqa 9 | from libtree.core.tree import * # noqa 10 | -------------------------------------------------------------------------------- /libtree/core/database.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | import os 5 | 6 | REQUIRED_POSTGRES_VERSION = (9, 5, 0) 7 | 8 | 9 | def create_schema(cur): 10 | """ Create table schema. """ 11 | script_folder = os.path.split(os.path.realpath(__file__))[0] 12 | path = os.path.join(script_folder, '..', 'sql', 'schema.sql') 13 | cur.execute(open(path).read()) 14 | 15 | 16 | def create_triggers(cur): 17 | """ Create triggers. """ 18 | script_folder = os.path.split(os.path.realpath(__file__))[0] 19 | path = os.path.join(script_folder, '..', 'sql', 'triggers.sql') 20 | cur.execute(open(path).read()) 21 | 22 | 23 | def drop_tables(cur): 24 | """ Drop all tables. """ 25 | cur.execute("DROP TABLE IF EXISTS nodes;") 26 | cur.execute("DROP TABLE IF EXISTS ancestors;") 27 | 28 | 29 | def flush_tables(cur): 30 | """ Empty all tables. """ 31 | cur.execute("TRUNCATE TABLE nodes RESTART IDENTITY;") 32 | cur.execute("TRUNCATE TABLE ancestors;") 33 | 34 | 35 | def is_compatible_postgres_version(cur): 36 | """ 37 | Determine whether PostgreSQL server version is compatible with 38 | libtree. 39 | """ 40 | cur.execute("SHOW server_version;") 41 | result = cur.fetchone()['server_version'] 42 | server_version = tuple(map(int, result.split('.'))) 43 | return server_version >= REQUIRED_POSTGRES_VERSION 44 | 45 | 46 | def is_installed(cur): 47 | """ Check whether libtree tables exist. """ 48 | return (table_exists(cur, 'nodes') and table_exists(cur, 'ancestors')) 49 | 50 | 51 | def make_dsn_from_env(env): 52 | """ 53 | Make DSN string from libpq environment variables. 54 | """ 55 | ret = [] 56 | mapping = { 57 | 'PGHOST': 'host', 58 | 'PGPORT': 'port', 59 | 'PGUSER': 'user', 60 | 'PGPASSWORD': 'password', 61 | 'PGDATABASE': 'dbname' 62 | } 63 | 64 | for env_name in ('PGHOST', 'PGPORT', 'PGUSER', 'PGPASSWORD', 'PGDATABASE'): 65 | value = env.get(env_name) 66 | if value: 67 | dsn_name = mapping[env_name] 68 | ret.append('{}={}'.format(dsn_name, value)) 69 | 70 | return ' '.join(ret) 71 | 72 | 73 | def table_exists(cur, table_name, schema='public'): 74 | """ Check if given table name exists. """ 75 | sql = """ 76 | SELECT EXISTS ( 77 | SELECT 78 | 1 79 | FROM 80 | information_schema.tables 81 | WHERE 82 | table_schema = %s 83 | AND 84 | table_name = %s 85 | ); 86 | """ 87 | cur.execute(sql, (schema, table_name)) 88 | result = cur.fetchone() 89 | return result['exists'] is True 90 | -------------------------------------------------------------------------------- /libtree/core/node_data.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | class NodeData(object): 5 | """ 6 | Immutable data-holding object which represents tree node data. Its 7 | attributes are identical to the columns in the ``nodes`` table 8 | (see :ref:`db_model`). 9 | 10 | Since the object is immutable, you must retrieve a new instance 11 | of the same node using :func:`libtree.core.query.get_node` to get 12 | updated values. 13 | 14 | To manipulate the values, you must use one of the following 15 | functions: 16 | 17 | * :func:`libtree.core.tree.change_parent` 18 | * :ref:`core-positioning` 19 | * :ref:`core-properties` 20 | 21 | Most ``libtree`` functions need a database ID in order to know on 22 | which data they should operate, but also accept ``Node`` objects 23 | to make handling with them easier. 24 | 25 | All parameters are optional and default to ``None``. 26 | 27 | :param int id: ID of the node as returned from the database 28 | :param parent: Reference to a parent node 29 | :type parent: Node or int 30 | :param int position: Position in between siblings 31 | (see :ref:`core-positioning`) 32 | :param dict properties: Inheritable key/value pairs 33 | (see :ref:`core-properties`) 34 | """ 35 | __slots__ = [ 36 | '_NodeData__id', 37 | '_NodeData__parent', 38 | '_NodeData__position', 39 | '_NodeData__properties', 40 | ] 41 | 42 | def __init__(self, id=None, parent=None, position=None, properties=None): 43 | self.__id = None 44 | if id is not None: 45 | self.__id = id 46 | 47 | self.__parent = None 48 | if parent is not None: 49 | self.__parent = parent 50 | 51 | self.__position = None 52 | if position is not None: 53 | self.__position = int(position) 54 | 55 | if isinstance(properties, dict): 56 | self.__properties = properties 57 | else: 58 | self.__properties = {} 59 | 60 | def __str__(self): 61 | return self.id 62 | 63 | def to_dict(self): 64 | """ Return dictionary containing all values of the object. """ 65 | return { 66 | 'id': self.id, 67 | 'parent': self.parent, 68 | 'position': self.position, 69 | 'properties': self.properties 70 | } 71 | 72 | def __repr__(self): 73 | if 'title' in self.properties: 74 | ret = "<NodeData id={!r}, title='{!s}'>" 75 | return ret.format(self.id, self.properties['title']) 76 | else: 77 | ret = '<NodeData id={!r}>' 78 | return ret.format(self.id, self.parent) 79 | 80 | @property 81 | def id(self): 82 | """ Node ID """ 83 | return self.__id 84 | 85 | @property 86 | def parent(self): 87 | """ Parent ID """ 88 | return self.__parent 89 | 90 | @property 91 | def position(self): 92 | """ Position in between its siblings """ 93 | return self.__position 94 | 95 | @property 96 | def properties(self): 97 | """ Node properties """ 98 | return self.__properties 99 | -------------------------------------------------------------------------------- /libtree/core/positioning.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | """ 5 | **Auto position** 6 | 7 | ``libtree`` has a feature called `auto position` which is turned on by 8 | default and makes sure that whenever you insert, move or delete a node 9 | its siblings stay correctly ordered. 10 | 11 | Let's assume you have a node sequence like this:: 12 | 13 | position | 0 | 1 14 | node | A | B 15 | 16 | If you now insert a new node without any further arguments, auto 17 | position will insert it at the end of the sequence:: 18 | 19 | position | 0 | 1 | 2 20 | node | A | B | C 21 | 22 | But if you insert the node at a certain position (1 in this example), 23 | auto position will free the desired spot and shift the following 24 | siblings to the right like this:: 25 | 26 | position | 0 | 1 | 2 27 | node | A | C | B 28 | 29 | Likewise, if you want to delete the node at position 1, auto position 30 | will left-shift all following nodes, so you end up with the same 31 | sequence as at the beginning again. 32 | 33 | This is default behaviour because most users expect a tree 34 | implementation to behave like this. 35 | 36 | 37 | **Disable auto position** 38 | 39 | If you're working on a dataset in which you know the final positions of 40 | your nodes before feeding them into ``libtree``, you can disable auto 41 | position altogether. This means lesser queries to the database and thus, 42 | faster insert speeds. On the other hand this means that no constraint 43 | checks are being made and you could end up with non-continuative 44 | position sequences, multiple nodes at the same position or no position 45 | at all. Don't worry - libtree supports those cases perfectly well - but 46 | it might be confusing in the end. 47 | 48 | To disable auto position you must pass ``auto_position=False`` to any 49 | function that manipulates the tree (see :ref:`core-tree`). 50 | 51 | 52 | **API** 53 | 54 | Related: :func:`libtree.query.get_node_at_position` 55 | """ 56 | 57 | from libtree.core.query import get_node, get_node_at_position 58 | 59 | 60 | def ensure_free_position(cur, node, position): 61 | """ 62 | Move siblings away to have a free slot at ``position`` in the 63 | children of ``node``. 64 | 65 | :param node: 66 | :type node: Node or uuid4 67 | :param int position: 68 | """ 69 | try: 70 | get_node_at_position(cur, node, position) 71 | node_exists_at_position = True 72 | except ValueError: 73 | node_exists_at_position = False 74 | 75 | if node_exists_at_position: 76 | shift_positions(cur, node, position, +1) 77 | 78 | 79 | def find_highest_position(cur, node): 80 | """ 81 | Return highest, not occupied position in the children of ``node``. 82 | 83 | :param node: 84 | :type node: Node or uuid4 85 | """ 86 | if node is not None: 87 | id = str(node) 88 | else: 89 | id = None 90 | 91 | sql = """ 92 | SELECT 93 | MAX(position) 94 | FROM 95 | nodes 96 | WHERE 97 | parent=%s; 98 | """ 99 | cur.execute(sql, (id, )) 100 | result = cur.fetchone()['max'] 101 | 102 | if result is not None: 103 | return result 104 | else: 105 | return -1 106 | 107 | 108 | def set_position(cur, node, position, auto_position=True): 109 | """ 110 | Set ``position`` for ``node``. 111 | 112 | :param node: 113 | :type node: Node or uuid4 114 | :param int position: Position in between siblings. If 0, the node 115 | will be inserted at the beginning of the 116 | parents children. If -1, the node will be 117 | inserted the the end of the parents children. 118 | If `auto_position` is disabled, this is just a 119 | value. 120 | :param bool auto_position: See :ref:`core-positioning` 121 | """ 122 | if auto_position: 123 | id = str(node) 124 | if isinstance(node, str): 125 | node = get_node(cur, id) 126 | 127 | if isinstance(position, int) and position >= 0: 128 | ensure_free_position(cur, node.parent, position) 129 | else: 130 | position = find_highest_position(cur, node.parent) + 1 131 | else: 132 | id = str(node) 133 | 134 | sql = """ 135 | UPDATE 136 | nodes 137 | SET 138 | position=%s 139 | WHERE 140 | id=%s; 141 | """ 142 | cur.execute(sql, (position, str(node))) 143 | return position 144 | 145 | 146 | def shift_positions(cur, node, position, offset): 147 | """ 148 | Shift all children of ``node`` at ``position`` by ``offset``. 149 | 150 | :param node: 151 | :type node: Node or uuid4 152 | :param int position: 153 | :param int offset: Positive value for right shift, negative value 154 | for left shift 155 | """ 156 | if node is not None: 157 | id = str(node) 158 | else: 159 | id = None 160 | 161 | sql = """ 162 | UPDATE 163 | nodes 164 | SET 165 | position=position{} 166 | WHERE 167 | parent=%s 168 | AND 169 | position >= %s; 170 | """ 171 | delta = '' 172 | if offset > 0: 173 | delta = '+{}'.format(offset) 174 | elif offset < 0: 175 | delta = '{}'.format(offset) 176 | 177 | sql = sql.format(delta) 178 | cur.execute(sql, (id, position)) 179 | 180 | 181 | def swap_node_positions(cur, node1, node2): 182 | """ 183 | Swap positions of ``node1`` and ``node2``. 184 | 185 | :param node1: 186 | :type node1: Node or uuid4 187 | :param node2: 188 | :type node2: Node or uuid4 189 | """ 190 | set_position(cur, node1, node2.position, auto_position=False) 191 | set_position(cur, node2, node1.position, auto_position=False) 192 | -------------------------------------------------------------------------------- /libtree/core/properties.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | import json 5 | 6 | from libtree.core.node_data import NodeData 7 | from libtree.core.query import get_ancestors, get_node 8 | from libtree.utils import recursive_dict_merge 9 | 10 | 11 | def get_nodes_by_property_dict(cur, query): 12 | """ 13 | Return an iterator that yields a ``NodeData`` object of every node 14 | which contains all key/value pairs of ``query`` in its property 15 | dictionary. Inherited keys are not considered. 16 | 17 | :param dict query: The dictionary to search for 18 | """ 19 | sql = """ 20 | SELECT 21 | * 22 | FROM 23 | nodes 24 | WHERE 25 | properties @> %s; 26 | """ 27 | cur.execute(sql, (json.dumps(query), )) 28 | for result in cur: 29 | yield NodeData(**result) 30 | 31 | 32 | def get_nodes_by_property_key(cur, key): 33 | """ 34 | Return an iterator that yields a ``NodeData`` object of every node 35 | which contains ``key`` in its property dictionary. Inherited keys 36 | are not considered. 37 | 38 | :param str key: The key to search for 39 | """ 40 | sql = """ 41 | SELECT 42 | * 43 | FROM 44 | nodes 45 | WHERE 46 | properties ? %s; 47 | """ 48 | cur.execute(sql, (key, )) 49 | for result in cur: 50 | yield NodeData(**result) 51 | 52 | 53 | def get_nodes_by_property_value(cur, key, value): 54 | """ 55 | Return an iterator that yields a ``NodeData`` object of every node 56 | which has ``key`` exactly set to ``value`` in its property 57 | dictionary. Inherited keys are not considered. 58 | 59 | :param str key: The key to search for 60 | :param object value: The exact value to sarch for 61 | """ 62 | query = {key: value} 63 | for node in get_nodes_by_property_dict(cur, query): 64 | yield node 65 | 66 | 67 | def get_inherited_properties(cur, node): 68 | """ 69 | Get the entire inherited property dictionary. 70 | 71 | To calculate this, the trees path from root node till ``node`` will 72 | be traversed. For each level, the property dictionary will be merged 73 | into the previous one. This is a simple merge, only the first level 74 | of keys will be combined. 75 | 76 | :param node: 77 | :type node: Node or uuid4 78 | :rtype: dict 79 | """ 80 | ret = {} 81 | id = str(node) 82 | if isinstance(node, str): 83 | node = get_node(cur, id) 84 | 85 | ancestors = list(get_ancestors(cur, id)) 86 | 87 | for ancestor in ancestors[::-1]: # Go top down 88 | ret.update(ancestor.properties) 89 | 90 | ret.update(node.properties) 91 | 92 | return ret 93 | 94 | 95 | def get_inherited_property_value(cur, node, key): 96 | """ 97 | Get the inherited value for a single property key. 98 | 99 | :param node: 100 | :type node: Node or uuid4 101 | :param key: str 102 | """ 103 | return get_inherited_properties(cur, node)[key] 104 | 105 | 106 | def get_recursive_properties(cur, node): 107 | """ 108 | Get the entire inherited and recursively merged property dictionary. 109 | 110 | To calculate this, the trees path from root node till ``node`` will 111 | be traversed. For each level, the property dictionary will be merged 112 | into the previous one. This is a recursive merge, so all dictionary 113 | levels will be combined. 114 | 115 | :param node: 116 | :type node: Node or uuid4 117 | :rtype: dict 118 | """ 119 | ret = {} 120 | id = str(node) 121 | if isinstance(node, str): 122 | node = get_node(cur, id) 123 | 124 | ancestors = list(get_ancestors(cur, id)) 125 | 126 | for ancestor in ancestors[::-1]: # Go top down 127 | recursive_dict_merge(ret, ancestor.properties, create_copy=False) 128 | 129 | recursive_dict_merge(ret, node.properties, create_copy=False) 130 | 131 | return ret 132 | 133 | 134 | def set_properties(cur, node, new_properties): 135 | """ 136 | Set the property dictionary to ``new_properties``. 137 | Return ``NodeData`` object with updated properties. 138 | 139 | :param node: 140 | :type node: Node or uuid4 141 | :param new_properties: dict 142 | """ 143 | if not isinstance(new_properties, dict): 144 | raise TypeError('Only dictionaries are supported.') 145 | 146 | id = str(node) 147 | if isinstance(node, str): 148 | node = get_node(cur, id) 149 | 150 | sql = """ 151 | UPDATE 152 | nodes 153 | SET 154 | properties=%s 155 | WHERE 156 | id=%s; 157 | """ 158 | cur.execute(sql, (json.dumps(new_properties), str(node))) 159 | 160 | kwargs = node.to_dict() 161 | kwargs['properties'] = new_properties 162 | return NodeData(**kwargs) 163 | 164 | 165 | def update_properties(cur, node, new_properties): 166 | """ 167 | Update existing property dictionary with another dictionary. 168 | Return ``NodeData`` object with updated properties. 169 | 170 | :param node: 171 | :type node: Node or uuid4 172 | :param new_properties: dict 173 | """ 174 | if not isinstance(new_properties, dict): 175 | raise TypeError('Only dictionaries are supported.') 176 | 177 | id = str(node) 178 | if isinstance(node, str): 179 | node = get_node(cur, id) 180 | 181 | properties = node.properties.copy() 182 | properties.update(new_properties) 183 | return set_properties(cur, node, properties) 184 | 185 | 186 | def set_property_value(cur, node, key, value): 187 | """ 188 | Set the value for a single property key. 189 | Return ``NodeData`` object with updated properties. 190 | 191 | :param node: 192 | :type node: Node or uuid4 193 | :param key: str 194 | :param value: object 195 | """ 196 | id = str(node) 197 | if isinstance(node, str): 198 | node = get_node(cur, id) 199 | 200 | properties = node.properties.copy() 201 | properties[key] = value 202 | set_properties(cur, node, properties) 203 | 204 | kwargs = node.to_dict() 205 | kwargs['properties'] = properties 206 | return NodeData(**kwargs) 207 | -------------------------------------------------------------------------------- /libtree/core/query.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | from libtree import exceptions 5 | from libtree.core.node_data import NodeData 6 | from libtree.utils import vectorize_nodes 7 | 8 | 9 | def get_tree_size(cur): 10 | """ 11 | Return the total amount of tree nodes. 12 | """ 13 | sql = """ 14 | SELECT 15 | COUNT(*) 16 | FROM 17 | nodes; 18 | """ 19 | cur.execute(sql) 20 | result = cur.fetchone() 21 | return result['count'] 22 | 23 | 24 | def get_root_node(cur): 25 | """ 26 | Return root node. Raise ``ValueError`` if root node doesn't exist. 27 | """ 28 | sql = """ 29 | SELECT 30 | * 31 | FROM 32 | nodes 33 | WHERE 34 | parent IS NULL; 35 | """ 36 | cur.execute(sql) 37 | result = cur.fetchone() 38 | 39 | if result is None: 40 | raise exceptions.NoRootNode() 41 | else: 42 | return NodeData(**result) 43 | 44 | 45 | def get_node(cur, id): 46 | """ 47 | Return ``NodeData`` object for given ``id``. Raises ``ValueError`` 48 | if ID doesn't exist. 49 | 50 | :param uuid4 id: Database ID 51 | """ 52 | sql = """ 53 | SELECT 54 | * 55 | FROM 56 | nodes 57 | WHERE 58 | id = %s; 59 | """ 60 | if not isinstance(id, str): 61 | raise TypeError('ID must be type string (UUID4).') 62 | 63 | cur.execute(sql, (id, )) 64 | result = cur.fetchone() 65 | 66 | if result is None: 67 | raise exceptions.NodeNotFound(id) 68 | else: 69 | return NodeData(**result) 70 | 71 | 72 | def get_node_at_position(cur, node, position): 73 | """ 74 | Return node at ``position`` in the children of ``node``. 75 | 76 | :param node: 77 | :type node: Node or uuid4 78 | :param int position: 79 | """ 80 | sql = """ 81 | SELECT 82 | * 83 | FROM 84 | nodes 85 | WHERE 86 | parent=%s 87 | AND 88 | position=%s 89 | """ 90 | 91 | cur.execute(sql, (str(node), position)) 92 | result = cur.fetchone() 93 | 94 | if result is None: 95 | raise ValueError('Node does not exist.') 96 | else: 97 | return NodeData(**result) 98 | 99 | 100 | def get_children(cur, node): 101 | """ 102 | Return an iterator that yields a ``NodeData`` object of every 103 | immediate child. 104 | 105 | :param node: 106 | :type node: Node or uuid4 107 | """ 108 | sql = """ 109 | SELECT 110 | * 111 | FROM 112 | nodes 113 | WHERE 114 | parent=%s 115 | ORDER BY 116 | position; 117 | """ 118 | cur.execute(sql, (str(node), )) 119 | for result in cur: 120 | yield NodeData(**result) 121 | 122 | 123 | def get_child_ids(cur, node): 124 | """ 125 | Return an iterator that yields the ID of every immediate child. 126 | 127 | :param node: 128 | :type node: Node or uuid4 129 | """ 130 | sql = """ 131 | SELECT 132 | id 133 | FROM 134 | nodes 135 | WHERE 136 | parent=%s 137 | ORDER BY 138 | position; 139 | """ 140 | cur.execute(sql, (str(node), )) 141 | for result in cur: 142 | yield str(result['id']) 143 | 144 | 145 | def get_children_count(cur, node): 146 | """ 147 | Get amount of immediate children. 148 | 149 | :param node: Node 150 | :type node: Node or uuid4 151 | """ 152 | sql = """ 153 | SELECT 154 | COUNT(*) 155 | FROM 156 | nodes 157 | WHERE 158 | parent=%s; 159 | """ 160 | cur.execute(sql, (str(node), )) 161 | result = cur.fetchone() 162 | return result['count'] 163 | 164 | 165 | def get_ancestors(cur, node, sort=True): 166 | """ 167 | Return an iterator which yields a ``NodeData`` object for every 168 | node in the hierarchy chain from ``node`` to root node. 169 | 170 | :param node: 171 | :type node: Node or uuid4 172 | :param bool sort: Start with closest node and end with root node. 173 | (default: True). Set to False if order is 174 | unimportant. 175 | """ 176 | # TODO: benchmark if vectorize_nodes() or WITH RECURSIVE is faster 177 | sql = """ 178 | SELECT 179 | nodes.* 180 | FROM 181 | ancestors 182 | INNER JOIN 183 | nodes 184 | ON 185 | ancestors.ancestor=nodes.id 186 | WHERE 187 | ancestors.node=%s; 188 | """ 189 | cur.execute(sql, (str(node), )) 190 | 191 | if sort: 192 | make_node = lambda r: NodeData(**r) 193 | for node in vectorize_nodes(map(make_node, cur))[::-1]: 194 | yield node 195 | else: 196 | for result in cur: 197 | yield NodeData(**result) 198 | 199 | 200 | def get_ancestor_ids(cur, node): 201 | """ 202 | Return an iterator that yields the ID of every element while 203 | traversing from ``node`` to the root node. 204 | 205 | :param node: 206 | :type node: Node or uuid4 207 | """ 208 | # TODO: add sort parameter 209 | sql = """ 210 | SELECT 211 | ancestor 212 | FROM 213 | ancestors 214 | WHERE 215 | node=%s; 216 | """ 217 | cur.execute(sql, (str(node), )) 218 | for result in cur: 219 | yield str(result['ancestor']) 220 | 221 | 222 | def get_descendants(cur, node): 223 | """ 224 | Return an iterator that yields the ID of every element while 225 | traversing from ``node`` to the root node. 226 | 227 | :param node: 228 | :type node: Node or uuid4 229 | """ 230 | sql = """ 231 | SELECT 232 | nodes.* 233 | FROM 234 | ancestors 235 | INNER JOIN 236 | nodes 237 | ON 238 | ancestors.node=nodes.id 239 | WHERE 240 | ancestors.ancestor=%s; 241 | """ 242 | cur.execute(sql, (str(node), )) 243 | for result in cur: 244 | yield NodeData(**result) 245 | 246 | 247 | def get_descendant_ids(cur, node): 248 | """ 249 | Return an iterator that yields a ``NodeData`` object of each element 250 | in the nodes subtree. Be careful when converting this iterator to an 251 | iterable (like list or set) because it could contain billions of 252 | objects. 253 | 254 | :param node: 255 | :type node: Node or uuid4 256 | """ 257 | sql = """ 258 | SELECT 259 | node 260 | FROM 261 | ancestors 262 | WHERE 263 | ancestor=%s; 264 | """ 265 | cur.execute(sql, (str(node), )) 266 | for result in cur: 267 | yield str(result['node']) 268 | -------------------------------------------------------------------------------- /libtree/core/tree.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | import json 5 | import uuid 6 | 7 | from libtree import exceptions 8 | from libtree.core.node_data import NodeData 9 | from libtree.core.positioning import (ensure_free_position, 10 | find_highest_position, shift_positions) 11 | from libtree.core.query import (get_children, get_descendant_ids, get_node, 12 | get_root_node) 13 | 14 | 15 | def print_tree(cur, start_node=None, indent=' ', _level=0): 16 | """ 17 | Print tree to stdout. 18 | 19 | :param start_node: Starting point for tree output. 20 | If ``None``, start at root node. 21 | :type start_node: int, Node, NodaData or None 22 | :param str indent: String to print per level (default: ' ') 23 | """ 24 | if start_node is None: 25 | start_node = get_root_node(cur) 26 | 27 | print('{}{}'.format(indent * _level, repr(start_node))) # noqa 28 | 29 | for child in list(get_children(cur, start_node)): 30 | print_tree(cur, child, indent=indent, _level=_level + 1) 31 | 32 | 33 | def insert_node(cur, parent, properties=None, position=None, 34 | auto_position=True, id=None): 35 | """ 36 | Create a ``Node`` object, insert it into the tree and then return 37 | it. 38 | 39 | :param parent: Reference to its parent node. If `None`, this will 40 | be the root node. 41 | :type parent: Node or uuid4 42 | :param dict properties: Inheritable key/value pairs 43 | (see :ref:`core-properties`) 44 | :param int position: Position in between siblings. If 0, the node 45 | will be inserted at the beginning of the 46 | parents children. If -1, the node will be 47 | inserted the the end of the parents children. 48 | If `auto_position` is disabled, this is just a 49 | value. 50 | :param bool auto_position: See :ref:`core-positioning` 51 | :param uuid4 id: Use this ID instead of automatically generating 52 | one. 53 | """ 54 | if id is None: 55 | id = str(uuid.uuid4()) 56 | 57 | parent_id = None 58 | if parent is not None: 59 | parent_id = str(parent) 60 | 61 | if properties is None: 62 | properties = {} 63 | 64 | # Can't run set_position() because the node doesn't exist yet 65 | if auto_position: 66 | if isinstance(position, int) and position >= 0: 67 | ensure_free_position(cur, parent, position) 68 | else: 69 | position = find_highest_position(cur, parent) + 1 70 | 71 | sql = """ 72 | INSERT INTO 73 | nodes 74 | (id, parent, position, properties) 75 | VALUES 76 | (%s, %s, %s, %s); 77 | """ 78 | cur.execute(sql, (id, parent_id, position, json.dumps(properties))) 79 | 80 | return NodeData(id, parent_id, position, properties) 81 | 82 | 83 | def delete_node(cur, node, auto_position=True): 84 | """ 85 | Delete node and its subtree. 86 | 87 | :param node: 88 | :type node: Node or uuid4 89 | :param bool auto_position: See :ref:`core-positioning` 90 | """ 91 | id = str(node) 92 | 93 | # Get Node object if integer (ID) was passed 94 | if auto_position and not isinstance(node, NodeData): 95 | node = get_node(cur, id) 96 | 97 | sql = """ 98 | DELETE FROM 99 | nodes 100 | WHERE 101 | id=%s; 102 | """ 103 | cur.execute(sql, (id, )) 104 | 105 | if auto_position: 106 | shift_positions(cur, node.parent, node.position, -1) 107 | 108 | 109 | def change_parent(cur, node, new_parent, position=None, auto_position=True): 110 | """ 111 | Move node and its subtree from its current to another parent node. 112 | Return updated ``Node`` object with new parent set. Raise 113 | ``ValueError`` if ``new_parent`` is inside ``node`` s subtree. 114 | 115 | :param node: 116 | :type node: Node or uuid4 117 | :param new_parent: Reference to the new parent node 118 | :type new_parent: Node or uuid4 119 | :param int position: Position in between siblings. If 0, the node 120 | will be inserted at the beginning of the 121 | parents children. If -1, the node will be 122 | inserted the the end of the parents children. 123 | If `auto_position` is disabled, this is just a 124 | value. 125 | :param bool auto_position: See :ref:`core-positioning`. 126 | """ 127 | new_parent_id = str(new_parent) 128 | if new_parent_id in get_descendant_ids(cur, node): 129 | raise exceptions.CantMoveIntoOwnSubtree() 130 | 131 | # Can't run set_position() here because the node hasn't been moved yet, 132 | # must do it manually 133 | if auto_position: 134 | if isinstance(position, int) and position >= 0: 135 | ensure_free_position(cur, new_parent_id, position) 136 | else: 137 | position = find_highest_position(cur, new_parent_id) + 1 138 | 139 | sql = """ 140 | UPDATE 141 | nodes 142 | SET 143 | parent=%s, 144 | position=%s 145 | WHERE 146 | id=%s; 147 | """ 148 | cur.execute(sql, (new_parent_id, position, str(node))) 149 | 150 | if isinstance(node, str): 151 | node = get_node(cur, node) 152 | 153 | kwargs = node.to_dict() 154 | kwargs['parent'] = new_parent_id 155 | kwargs['position'] = position 156 | return NodeData(**kwargs) 157 | -------------------------------------------------------------------------------- /libtree/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | class CantMoveIntoOwnSubtree(Exception): 5 | pass 6 | 7 | 8 | class NoRootNode(Exception): 9 | pass 10 | 11 | 12 | class NodeNotFound(Exception): 13 | pass 14 | -------------------------------------------------------------------------------- /libtree/node.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | from libtree import core, utils 5 | 6 | 7 | class Node: 8 | """ 9 | Representation of a tree node and entrypoint for local tree 10 | operations. 11 | 12 | It's a thin wrapper around the underlaying core functions. It does 13 | not contain any data besides the database ID and must therefore 14 | query the database every time the value of an attribute like 15 | ``parent`` has been requested. This decision has been made to avoid 16 | race conditions when working in concurrent or distributed 17 | environments, but comes at the cost of slower runtime execution 18 | speeds. If this becomes a problem for you, grab the the 19 | corresponding :class:`libtree.core.node_data.NodeData` object via 20 | :attr:`libtree.node.Node.node_data`. 21 | 22 | This object is tightly coupled to a 23 | :class:`libtree.transaction.Transaction` object. It behaves like a 24 | partial which passes a database cursor and node ID into every 25 | :mod:`libtree.core` function. It also has a few convenience features 26 | like attribute access via Python properties and shorter method 27 | names. 28 | 29 | :param transaction: Transaction object 30 | :type transaction: Transaction 31 | :param uuid4 id: Database node ID 32 | .. automethod:: __len__ 33 | .. automethod:: __eq__ 34 | 35 | """ 36 | __slots__ = [ 37 | '_cursor', 38 | '_Node__id', 39 | '_transaction' 40 | ] 41 | 42 | def __init__(self, transaction, id): 43 | self.__id = id 44 | 45 | self._transaction = transaction 46 | self._cursor = transaction.cursor 47 | 48 | def __repr__(self): 49 | if 'title' in self.properties: 50 | ret = "<Node id={!r}, title='{!s}'>" 51 | return ret.format(self.id, self.properties['title']) 52 | else: 53 | ret = '<Node id={!r}>' 54 | return ret.format(self.id) 55 | 56 | def __eq__(self, other): 57 | """ Determine if this node is equal to ``other``. """ 58 | if other.__class__ == Node: 59 | nd_self = self.node_data 60 | nd_other = core.get_node(self._cursor, other.id) 61 | return nd_self.to_dict() == nd_other.to_dict() 62 | return False 63 | 64 | def __hash__(self): 65 | return hash('<Node {}>'.format(self.id)) 66 | 67 | def __len__(self): 68 | """ Return amount of child nodes. """ 69 | return int(core.get_children_count(self._cursor, self.id)) 70 | 71 | @property 72 | def id(self): 73 | """ Database ID """ 74 | return self.__id 75 | 76 | @property 77 | def node_data(self): 78 | """ 79 | Get a :class:`libtree.core.node_data.NodeData` object for 80 | current node ID from database. 81 | """ 82 | return core.get_node(self._cursor, self.id) 83 | 84 | @property 85 | def parent(self): 86 | """ Get parent node. """ 87 | parent = self.node_data.parent 88 | if parent is not None: 89 | return Node(self._transaction, self.node_data.parent) 90 | return None 91 | 92 | @property 93 | def position(self): 94 | """ Get position in between sibling nodes. """ 95 | return self.node_data.position 96 | 97 | @property 98 | def properties(self): 99 | """ Get property dictionary. """ 100 | return self.node_data.properties 101 | 102 | @property 103 | def inherited_properties(self): 104 | """ Get inherited property dictionary. """ 105 | return core.get_inherited_properties(self._cursor, self.id) 106 | 107 | @property 108 | def recursive_properties(self): 109 | """ 110 | Get inherited and recursively merged property dictionary. 111 | """ 112 | return core.get_recursive_properties(self._cursor, self.id) 113 | 114 | @property 115 | def children(self): 116 | """ Get list of immediate child nodes. """ 117 | ret = [] 118 | for _id in core.get_child_ids(self._cursor, self.id): 119 | node = Node(self._transaction, _id) 120 | ret.append(node) 121 | return ret 122 | 123 | @property 124 | def has_children(self): 125 | """ Return whether immediate children exist. """ 126 | return core.get_children_count(self._cursor, self.id) > 0 127 | 128 | @property 129 | def ancestors(self): 130 | """ Get bottom-up ordered list of ancestor nodes. """ 131 | ret = [] 132 | for node in core.get_ancestors(self._cursor, self.id, sort=True): 133 | node = Node(self._transaction, node.id) 134 | ret.append(node) 135 | return utils.vectorize_nodes(ret)[::-1] 136 | 137 | @property 138 | def descendants(self): 139 | """ Get set of descendant nodes. """ 140 | ret = set() 141 | for _id in core.get_descendant_ids(self._cursor, self.id): 142 | node = Node(self._transaction, _id) 143 | ret.add(node) 144 | return ret 145 | 146 | def delete(self): 147 | """ Delete node and its subtree. """ 148 | return core.delete_node(self._cursor, self.id) 149 | 150 | def insert_child(self, properties=None, position=-1, id=None): 151 | """ 152 | Create a child node and return it. 153 | 154 | :param dict properties: Inheritable key/value pairs 155 | (see :ref:`core-properties`) 156 | :param int position: Position in between siblings. If 0, the 157 | node will be inserted at the beginning of 158 | the parents children. If -1, the node will 159 | be inserted the the end of the parents 160 | children. 161 | :param uuid4 id: Use this ID instead of automatically generating 162 | one. 163 | """ 164 | node_data = core.insert_node(self._cursor, self.id, properties, 165 | position=position, auto_position=True, 166 | id=id) 167 | return Node(self._transaction, node_data.id) 168 | 169 | def move(self, target, position=-1): 170 | """ 171 | Move node and its subtree from its current to another parent 172 | node. Raises ``ValueError`` if ``target`` is inside this nodes' 173 | subtree. 174 | 175 | :param target: New parent node 176 | :type target: Node 177 | :param int position: Position in between siblings. If 0, the 178 | node will be inserted at the beginning of 179 | the parents children. If -1, the node will 180 | be inserted the the end of the parents 181 | children. 182 | """ 183 | core.change_parent(self._cursor, self.id, target.id, 184 | position=position, auto_position=True) 185 | 186 | def swap_position(self, other): 187 | """ 188 | Swap position with ``other`` position. 189 | 190 | :param other: Node to swap the position with 191 | :type other: Node 192 | """ 193 | core.swap_node_positions(self._cursor, self.id, other.id) 194 | 195 | def set_properties(self, properties): 196 | """ 197 | Set properties. 198 | 199 | :param dict properties: Property dictionary 200 | """ 201 | core.set_properties(self._cursor, self.id, properties) 202 | 203 | def update_properties(self, properties): 204 | """ 205 | Set properties. 206 | 207 | :param dict properties: Property dictionary 208 | """ 209 | core.update_properties(self._cursor, self.id, properties) 210 | 211 | def set_position(self, new_position): 212 | """ 213 | Set position. 214 | 215 | :param int position: Position in between siblings. If 0, the 216 | node will be inserted at the beginning of 217 | the parents children. If -1, the node will 218 | be inserted the the end of the parents 219 | children. 220 | """ 221 | core.set_position(self._cursor, self.id, new_position, 222 | auto_position=True) 223 | 224 | def get_child_at_position(self, position): 225 | """ 226 | Get child node at certain position. 227 | 228 | :param int position: Position to get the child node from 229 | """ 230 | return core.get_node_at_position(self._cursor, self.id, position) 231 | -------------------------------------------------------------------------------- /libtree/sql/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE ancestors 2 | ( 3 | node UUID NOT NULL, 4 | ancestor UUID NOT NULL, 5 | CONSTRAINT idx UNIQUE (node, ancestor) 6 | ) with ( oids=FALSE ); 7 | 8 | CREATE INDEX idx_ancestor ON ancestors USING btree (ancestor); 9 | 10 | CREATE INDEX idx_node ON ancestors USING btree (node); 11 | 12 | CREATE TABLE nodes 13 | ( 14 | id UUID NOT NULL, 15 | parent UUID, 16 | "position" SMALLINT DEFAULT NULL, 17 | properties JSONB NOT NULL, 18 | CONSTRAINT "primary" PRIMARY KEY (id) 19 | ) with ( oids=FALSE ); 20 | 21 | CREATE INDEX idx_parent ON nodes USING btree (parent); 22 | -------------------------------------------------------------------------------- /libtree/sql/triggers.sql: -------------------------------------------------------------------------------- 1 | /* AFTER INSERT */ 2 | CREATE OR REPLACE FUNCTION 3 | update_ancestors_after_insert() 4 | RETURNS TRIGGER AS 5 | $BODY$ 6 | BEGIN 7 | IF NEW.parent IS NOT NULL THEN 8 | 9 | INSERT INTO ancestors 10 | (node, 11 | ancestor) 12 | SELECT NEW.id, 13 | ancestor 14 | FROM ancestors 15 | WHERE node = NEW.parent 16 | UNION 17 | SELECT NEW.id, 18 | NEW.parent; 19 | END IF; 20 | 21 | RETURN NEW; 22 | END; 23 | $BODY$ 24 | LANGUAGE plpgsql; 25 | 26 | CREATE CONSTRAINT TRIGGER update_ancestors_after_insert 27 | AFTER INSERT 28 | ON nodes 29 | FOR EACH ROW 30 | EXECUTE PROCEDURE update_ancestors_after_insert(); 31 | 32 | 33 | /* AFTER DELETE */ 34 | CREATE OR REPLACE FUNCTION 35 | update_ancestors_after_delete() 36 | RETURNS TRIGGER AS 37 | $BODY$ 38 | BEGIN 39 | 40 | DELETE FROM nodes AS t1 41 | USING ancestors AS t2 42 | WHERE t2."ancestor" = OLD.id 43 | AND t1."id" = t2."node"; 44 | 45 | DELETE FROM ancestors AS t1 46 | USING ancestors AS t2 47 | WHERE t2."ancestor" = OLD.id 48 | AND t1."node" = t2."node"; 49 | 50 | DELETE FROM ancestors AS t1 51 | USING ancestors AS t2 52 | WHERE t2."ancestor" = OLD.id 53 | AND t1."ancestor" = t2."node"; 54 | 55 | DELETE FROM ancestors 56 | WHERE node = OLD.id 57 | OR ancestor = OLD.id; 58 | 59 | RETURN OLD; 60 | 61 | END; 62 | $BODY$ 63 | LANGUAGE plpgsql; 64 | 65 | CREATE CONSTRAINT TRIGGER update_ancestors_after_delete 66 | AFTER DELETE 67 | ON nodes 68 | FOR EACH ROW 69 | WHEN (pg_trigger_depth() = 0) 70 | EXECUTE PROCEDURE update_ancestors_after_delete(); 71 | 72 | 73 | /* AFTER UPDATE */ 74 | CREATE OR REPLACE FUNCTION 75 | update_ancestors_after_update() 76 | RETURNS TRIGGER AS 77 | $BODY$ 78 | BEGIN 79 | 80 | DELETE FROM ancestors 81 | WHERE ancestor IN (SELECT ancestor 82 | FROM ancestors 83 | WHERE node = NEW.id) 84 | AND node IN (SELECT node 85 | FROM ancestors 86 | WHERE ancestor = NEW.id 87 | OR node = NEW.id); 88 | 89 | INSERT INTO ancestors 90 | SELECT sub.node, 91 | par.ancestor 92 | FROM ancestors AS sub 93 | JOIN (SELECT ancestor 94 | FROM ancestors 95 | WHERE node = NEW.parent 96 | UNION 97 | SELECT NEW.parent) AS par 98 | ON true 99 | WHERE sub.ancestor = NEW.id 100 | OR sub.node = NEW.id; 101 | 102 | IF NEW.parent IS NOT NULL THEN 103 | 104 | INSERT INTO ancestors 105 | (node, 106 | ancestor) 107 | SELECT NEW.id, 108 | ancestor 109 | FROM ancestors 110 | WHERE node = NEW.parent 111 | UNION 112 | SELECT NEW.id, 113 | NEW.parent; 114 | END IF; 115 | 116 | RETURN NEW; 117 | END; 118 | $BODY$ 119 | LANGUAGE plpgsql; 120 | 121 | CREATE CONSTRAINT TRIGGER update_ancestors_after_update 122 | AFTER UPDATE OF parent 123 | ON nodes 124 | FOR EACH ROW 125 | EXECUTE PROCEDURE update_ancestors_after_update(); 126 | -------------------------------------------------------------------------------- /libtree/transactions.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | try: 5 | from psycopg2cffi import compat 6 | except ImportError: 7 | pass 8 | else: 9 | compat.register() 10 | 11 | from psycopg2.extras import RealDictCursor 12 | 13 | from libtree import core 14 | 15 | 16 | class ReadWriteTransaction(object): 17 | """ 18 | Representation of a database transaction and entrypoint for global 19 | tree operations. 20 | 21 | :param connection: Postgres connection object. Its ``autocommit`` 22 | attribute will be set to ``False``. 23 | :type connection: Connection 24 | :param object node_factory: Factory class for creating node objects 25 | """ 26 | def __init__(self, connection, node_factory): 27 | self.connection = connection 28 | self.cursor = connection.cursor(cursor_factory=RealDictCursor) 29 | self.node_factory = node_factory 30 | self.connection.set_session(readonly=False) 31 | 32 | def commit(self): 33 | """ 34 | Write changes to databases. See `commit() 35 | <http://initd.org/psycopg/docs/connection.html#connection.commit>`_ 36 | . 37 | """ 38 | return self.connection.commit() 39 | 40 | def rollback(self): 41 | """ 42 | Discard changes. See `rollback() 43 | <http://initd.org/psycopg/docs/connection.html#connection.rollback>`_ 44 | . 45 | """ 46 | return self.connection.rollback() 47 | 48 | def is_compatible_postgres_version(self): 49 | """ 50 | Determine whether PostgreSQL server version is compatible with 51 | libtree. 52 | """ 53 | return core.is_compatible_postgres_version(self.cursor) 54 | 55 | def is_installed(self): 56 | """ Check whether `libtree` is installed. """ 57 | return core.is_installed(self.cursor) 58 | 59 | def install(self): 60 | """ 61 | Create tables and trigger functions in database. 62 | Return `False` if `libtree` was already installed, other `True`. 63 | """ 64 | if self.is_installed(): 65 | return False 66 | 67 | core.create_schema(self.cursor) 68 | core.create_triggers(self.cursor) 69 | 70 | return True 71 | 72 | def uninstall(self): 73 | """ Remove libtree tables from database. """ 74 | return core.drop_tables(self.cursor) 75 | 76 | def clear(self): 77 | """ Empty database tables. """ 78 | return core.flush_tables(self.cursor) 79 | 80 | def print_tree(self): 81 | """ Simple function to print tree structure to stdout. """ 82 | return core.print_tree(self.cursor) 83 | 84 | def get_tree_size(self): 85 | """ Get amount of nodes inside the tree. """ 86 | return core.get_tree_size(self.cursor) 87 | 88 | def get_root_node(self): 89 | """ Get root node if exists, other ``None``. """ 90 | node_id = core.get_root_node(self.cursor).id 91 | return self.node_factory(self, node_id) 92 | 93 | def insert_root_node(self, properties=None): 94 | """ 95 | Create root node, then get it. 96 | 97 | :param dict properties: Inheritable key/value pairs 98 | (see :ref:`core-properties`) 99 | """ 100 | node_id = core.insert_node(self.cursor, None, properties).id 101 | return self.node_factory(self, node_id) 102 | 103 | def get_node(self, node_id): 104 | """ 105 | Get node with given database ID. 106 | 107 | :param int node_id: Database ID 108 | """ 109 | node_id = core.get_node(self.cursor, node_id).id 110 | return self.node_factory(self, node_id) 111 | 112 | def get_nodes_by_property_dict(self, query): 113 | """ 114 | Get a set of nodes which have all key/value pairs of ``query`` 115 | in their properties. Inherited properties are not considered. 116 | 117 | :param dict query: The dictionary to search for 118 | """ 119 | ret = set() 120 | for _nd in core.get_nodes_by_property_dict(self.cursor, query): 121 | node = self.node_factory(self, _nd.id) 122 | ret.add(node) 123 | return ret 124 | 125 | def get_nodes_by_property_key(self, key): 126 | """ 127 | Get a set of nodes which have a property named ``key`` in their 128 | properties. Inherited properties are not considered. 129 | 130 | :param str key: The key to search for 131 | """ 132 | ret = set() 133 | for _nd in core.get_nodes_by_property_key(self.cursor, key): 134 | node = self.node_factory(self, _nd.id) 135 | ret.add(node) 136 | return ret 137 | 138 | def get_nodes_by_property_value(self, key, value): 139 | """ 140 | Get a set of nodes which have a property ``key`` with value 141 | ``value``. Inherited properties are not considered. 142 | 143 | :param str key: The key to search for 144 | :param object value: The exact value to sarch for 145 | """ 146 | ret = set() 147 | for _nd in core.get_nodes_by_property_value(self.cursor, key, value): 148 | node = self.node_factory(self, _nd.id) 149 | ret.add(node) 150 | return ret 151 | 152 | 153 | class ReadOnlyTransaction(ReadWriteTransaction): 154 | """ 155 | Representation of a read-only database transaction and entrypoint 156 | for global tree operations. 157 | 158 | :param connection: Postgres connection object. Its ``autocommit`` 159 | attribute will be set to ``False``. 160 | :type connection: Connection 161 | :param object node_factory: Factory class for creating node objects 162 | """ 163 | def __init__(self, connection, node_factory): 164 | self.connection = connection 165 | self.cursor = connection.cursor(cursor_factory=RealDictCursor) 166 | self.node_factory = node_factory 167 | self.connection.set_session(readonly=True) 168 | -------------------------------------------------------------------------------- /libtree/tree.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | from contextlib import contextmanager 5 | 6 | from libtree.node import Node 7 | from libtree.transactions import ReadOnlyTransaction, ReadWriteTransaction 8 | 9 | 10 | class Tree: 11 | """ 12 | Context manager for creating thread-safe transactions in which 13 | libtree operations can be executed. 14 | 15 | It yields a :class:`libtree.transaction.Transaction` object which 16 | can be used for accessing the tree. When the context manager gets 17 | exited, all changes will be committed to the database. If an 18 | exception occured, the transaction will be rolled back. 19 | 20 | It requires either a ``connection`` or ``pool`` object from the 21 | `psycopg2 <http://initd.org/psycopg/docs/index.html>`_ package. 22 | 23 | When libtree is used in a threaded environment (usually in 24 | production), it's recommended to use a `pool 25 | <http://initd.org/psycopg/docs/pool.html>`_ object. 26 | 27 | When libtree is used in a single-threaded environment (usually 28 | during development), it's enough to pass a standard `connection 29 | <http://initd.org/psycopg/docs/connection.html>`_ object. 30 | 31 | By default the built-in :class:`libtree.node.Node` class is used to 32 | create node objects, but it's possible to pass a different one via 33 | ``node_factory``. 34 | 35 | :param connection: psycopg2 connection object 36 | :type connection: psycopg2.connection 37 | :param pool: psycopg2 pool object 38 | :type pool: psycopg2.pool.ThreadedConnectionPool 39 | :param object node_factory: Factory class for creating node objects 40 | (default: 41 | :class:`libtree.node.Node`) 42 | """ 43 | def __init__(self, connection=None, pool=None, node_factory=Node): 44 | if connection is None and pool is None: 45 | msg = ( 46 | "__init__() missing 1 required positional argument:", 47 | "'connection' or 'pool" 48 | ) 49 | raise TypeError(' '.join(msg)) 50 | 51 | if connection is not None and pool is not None: 52 | msg = ( 53 | "__init__() accepts only 1 required positional argument:", 54 | "'connection' or 'pool" 55 | ) 56 | raise TypeError(' '.join(msg)) 57 | 58 | self.connection = connection 59 | self.node_factory = node_factory 60 | self.pool = pool 61 | 62 | @contextmanager 63 | def __call__(self, *args, **kwargs): 64 | transaction = self.make_transaction(*args, **kwargs) 65 | connection = transaction.connection 66 | 67 | try: 68 | yield transaction 69 | connection.commit() 70 | except Exception: 71 | connection.rollback() 72 | raise 73 | finally: 74 | if self.pool is not None: 75 | self.pool.putconn(connection) 76 | 77 | def get_connection(self): 78 | """ 79 | Return a connection from the pool or the manually assigned one. 80 | """ 81 | if self.pool is None: 82 | return self.connection 83 | else: 84 | return self.pool.getconn() 85 | 86 | def make_transaction(self, write=False): 87 | """ 88 | Get a new transaction object using a connection from the pool 89 | or the manually assigned one. 90 | 91 | :param bool write: Enable write access (default: False) 92 | """ 93 | connection = self.get_connection() 94 | 95 | if write: 96 | return ReadWriteTransaction(connection, self.node_factory) 97 | else: 98 | return ReadOnlyTransaction(connection, self.node_factory) 99 | 100 | def close(self): 101 | """ 102 | Close all connections in pool or the manually assigned one. 103 | """ 104 | if self.pool is not None: 105 | self.pool.closeall() 106 | else: 107 | self.connection.close() 108 | -------------------------------------------------------------------------------- /libtree/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | import collections 5 | from copy import deepcopy 6 | 7 | 8 | def recursive_dict_merge(left, right, create_copy=True): 9 | """ 10 | Merge ``right`` into ``left`` and return a new dictionary. 11 | """ 12 | if create_copy is True: 13 | left = deepcopy(left) 14 | 15 | for key in right: 16 | if key in left: 17 | if isinstance(left[key], dict) and isinstance(right[key], dict): 18 | recursive_dict_merge(left[key], right[key], False) 19 | else: 20 | left[key] = right[key] 21 | else: 22 | left[key] = right[key] 23 | return left 24 | 25 | 26 | def vectorize_nodes(*nodes): 27 | if len(nodes) == 1 and isinstance(nodes[0], collections.Iterable): 28 | nodes = nodes[0] 29 | 30 | ret = [] 31 | parents = {str(node.parent): node for node in nodes} 32 | 33 | last_parent = None 34 | for _ in range(len(parents)): 35 | node = parents[str(last_parent)] 36 | ret.append(node) 37 | last_parent = node 38 | 39 | return ret 40 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | try: 5 | from setuptools import setup, find_packages, Command 6 | except ImportError: 7 | from ez_setup import use_setuptools 8 | use_setuptools() 9 | from setuptools import setup, find_packages, Command 10 | 11 | import platform 12 | import os 13 | import subprocess 14 | import sys 15 | 16 | from setuptools.command.test import test as TestCommand 17 | 18 | 19 | class PyTest(TestCommand): 20 | user_options = [('pytest-args=', 'a', "Arguments to pass to py.test")] 21 | 22 | def initialize_options(self): 23 | TestCommand.initialize_options(self) 24 | self.pytest_args = [] 25 | 26 | def finalize_options(self): 27 | TestCommand.finalize_options(self) 28 | self.test_args = [] 29 | self.test_suite = True 30 | 31 | def run_tests(self): 32 | #import here, cause outside the eggs aren't loaded 33 | import pytest 34 | errno = pytest.main(self.pytest_args) 35 | sys.exit(errno) 36 | 37 | 38 | if platform.python_implementation() == 'PyPy': 39 | psycopg2_dependency = 'psycopg2cffi==2.7.2' 40 | else: 41 | psycopg2_dependency = 'psycopg2==2.6.1' 42 | 43 | 44 | setup( 45 | name='libtree', 46 | version='6.0.1', 47 | author='Fabian Kochem', 48 | author_email='fabian.kochem@concepts-and-training.de', 49 | description='Python Tree Library', 50 | url='https://github.com/conceptsandtraining/libtree', 51 | 52 | # Dependencies 53 | install_requires=[ 54 | psycopg2_dependency 55 | ], 56 | tests_require=[ 57 | 'pytest', 58 | 'mock' 59 | ], 60 | 61 | cmdclass={ 62 | 'test': PyTest 63 | }, 64 | entry_points={}, 65 | packages=find_packages(), 66 | zip_safe=False, 67 | include_package_data=True, 68 | 69 | classifiers=[ 70 | 'Development Status :: 3 - Alpha', 71 | 'Intended Audience :: Developers', 72 | 'License :: OSI Approved :: MIT License', 73 | 'Operating System :: OS Independent', 74 | 'Programming Language :: Python', 75 | 'Programming Language :: Python :: 2', 76 | 'Programming Language :: Python :: 3', 77 | 'Topic :: Database', 78 | 'Topic :: Software Development :: Libraries' 79 | ], 80 | ) 81 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | from libtree import Node, ReadWriteTransaction 5 | from libtree.core.database import make_dsn_from_env 6 | from libtree.core.query import get_node 7 | from libtree.core.tree import insert_node 8 | import os 9 | import pytest 10 | 11 | try: 12 | from psycopg2cffi import compat 13 | except ImportError: 14 | pass 15 | else: 16 | compat.register() 17 | 18 | import psycopg2 19 | 20 | 21 | """ 22 | Create this structure: 23 | 24 | / 25 | - nd1 26 | - nd2 27 | - nd2-1 28 | - nd2-1-1 29 | - nd2-leaf 30 | - nd3 31 | """ 32 | 33 | node_ids = {} 34 | 35 | 36 | def get_or_create_nd(cur, parent, properties, *args, **kwargs): 37 | xtype = properties.get('type') 38 | node_id = node_ids.get(xtype, None) 39 | if node_id is None: 40 | node = insert_node(cur, parent, properties=properties, *args, **kwargs) 41 | node_ids[xtype] = node.id 42 | return node 43 | return get_node(cur, node_id) 44 | 45 | 46 | @pytest.fixture(scope='module') 47 | def trans(request): 48 | dsn = make_dsn_from_env(os.environ) 49 | connection = psycopg2.connect(dsn) 50 | transaction = ReadWriteTransaction(connection, Node) 51 | 52 | if transaction.is_installed(): 53 | transaction.uninstall() 54 | 55 | node_ids.clear() 56 | transaction.install() 57 | transaction.commit() 58 | 59 | def fin(): 60 | transaction.uninstall() 61 | transaction.commit() 62 | request.addfinalizer(fin) 63 | 64 | return transaction 65 | 66 | 67 | @pytest.fixture(scope='module') 68 | def cur(trans): 69 | return trans.cursor 70 | 71 | 72 | @pytest.fixture 73 | def root(cur): 74 | props = { 75 | 'type': 'root', 76 | 'boolean': False, 77 | 'integer': 1, 78 | 'dict': {'key': 'value'}, 79 | 'list': [{'abc': 2}] 80 | } 81 | return get_or_create_nd(cur, None, auto_position=False, properties=props) 82 | 83 | 84 | @pytest.fixture 85 | def nd1(cur, root): 86 | props = { 87 | 'type': 'nd1', 88 | 'title': 'Node 1' 89 | } 90 | return get_or_create_nd(cur, root, position=4, auto_position=False, 91 | properties=props) 92 | 93 | 94 | @pytest.fixture 95 | def nd2(cur, root): 96 | props = { 97 | 'type': 'nd2', 98 | 'title': 'Node 2', 99 | 'boolean': True, 100 | 'foo': 'bar', 101 | 'dict': {'another key': 'another value'} 102 | } 103 | return get_or_create_nd(cur, root, position=5, auto_position=False, 104 | properties=props) 105 | 106 | 107 | @pytest.fixture 108 | def nd3(cur, root): 109 | props = { 110 | 'type': 'nd3', 111 | 'title': 'Node 3' 112 | } 113 | return get_or_create_nd(cur, root, position=6, auto_position=False, 114 | properties=props) 115 | 116 | 117 | @pytest.fixture 118 | def nd2_1(cur, nd2): 119 | props = { 120 | 'type': 'nd2_1', 121 | 'title': 'Node 2-1', 122 | 'dict': {'key': 'yet another value'} 123 | } 124 | return get_or_create_nd(cur, nd2, auto_position=False, 125 | properties=props) 126 | 127 | 128 | @pytest.fixture 129 | def nd2_1_1(cur, nd2_1): 130 | props = { 131 | 'type': 'nd2_1_1', 132 | 'title': 'Node 2-1-1', 133 | 'boolean': False, 134 | 'list': [{'def': 4}] 135 | } 136 | return get_or_create_nd(cur, nd2_1, auto_position=False, 137 | properties=props) 138 | 139 | 140 | @pytest.fixture 141 | def nd2_leaf(cur, nd2_1_1): 142 | props = { 143 | 'type': 'nd2_leaf', 144 | 'title': 'Node 2-leaf' 145 | } 146 | return get_or_create_nd(cur, nd2_1_1, auto_position=False, 147 | properties=props) 148 | -------------------------------------------------------------------------------- /tests/core/test_database.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | from uuid import uuid4 5 | 6 | from mock import Mock 7 | 8 | from libtree.core.database import (is_compatible_postgres_version, 9 | is_installed, make_dsn_from_env, 10 | table_exists) 11 | 12 | 13 | def test_is_compatible_postgres_version(): 14 | cur = Mock() 15 | cur.fetchone.return_value = {'server_version': '9.5.0'} 16 | assert is_compatible_postgres_version(cur) is True 17 | 18 | cur.fetchone.return_value = {'server_version': '9.3.8'} 19 | assert is_compatible_postgres_version(cur) is False 20 | 21 | 22 | def test_is_installed(trans, cur): 23 | assert is_installed(cur) is True 24 | 25 | trans.uninstall() 26 | assert is_installed(cur) is False 27 | 28 | trans.install() 29 | assert is_installed(cur) is True 30 | 31 | 32 | def test_make_dsn_from_env(): 33 | env = { 34 | 'PGHOST': 'localhost', 35 | 'PGPORT': 5432, 36 | 'PGUSER': 'foo', 37 | 'PGPASSWORD': 'secret', 38 | 'PGDATABASE': 'mydb' 39 | } 40 | 41 | conn_string = make_dsn_from_env(env) 42 | expected = 'host=localhost port=5432 user=foo password=secret dbname=mydb' 43 | assert conn_string == expected 44 | 45 | 46 | def test_table_exists(cur): 47 | assert table_exists(cur, 'nodes') is True 48 | assert table_exists(cur, str(uuid4()).split('-')[-1]) is False 49 | -------------------------------------------------------------------------------- /tests/core/test_node_data.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | from libtree.core.node_data import NodeData 5 | 6 | 7 | def test_basic_representation(): 8 | node = NodeData(11, 22) 9 | assert repr(node) == "<NodeData id=11>" 10 | 11 | 12 | def test_title_representation(): 13 | node = NodeData(11, 22, properties={'title': 'my test'}) 14 | assert repr(node) == "<NodeData id=11, title='my test'>" 15 | 16 | 17 | def test_to_dict_conversion(): 18 | kwargs = { 19 | 'id': 11, 20 | 'parent': 22, 21 | 'position': 4, 22 | 'properties': {'a': 1} 23 | } 24 | node = NodeData(**kwargs) 25 | assert node.to_dict() == kwargs 26 | -------------------------------------------------------------------------------- /tests/core/test_positioning.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | import uuid 5 | from pdb import set_trace as trace # noqa 6 | 7 | import pytest 8 | 9 | from libtree.core.positioning import (ensure_free_position, 10 | find_highest_position, set_position, 11 | shift_positions, swap_node_positions) 12 | from libtree.core.query import get_children, get_node, get_node_at_position 13 | from libtree.core.tree import change_parent, delete_node, insert_node 14 | 15 | 16 | def test_set_position(cur, root): 17 | set_position(cur, root, 0, auto_position=False) 18 | assert get_node(cur, root.id).position == 0 19 | 20 | 21 | def test_set_position_autoposition(cur, root, nd1, nd2, nd3): 22 | set_position(cur, nd1, 0, auto_position=True) 23 | set_position(cur, nd2, 2, auto_position=True) 24 | set_position(cur, nd3.id, -1, auto_position=True) 25 | assert get_node(cur, nd1.id).position == 0 26 | assert get_node(cur, nd2.id).position == 2 27 | assert get_node(cur, nd3.id).position == nd3.position + 1 28 | 29 | 30 | def test_set_positions_with_gap_in_sequence(cur, nd1, nd2, nd3): 31 | set_position(cur, nd1, 0, auto_position=False) 32 | set_position(cur, nd2, 1, auto_position=False) 33 | set_position(cur, nd3, 3, auto_position=False) 34 | assert get_node(cur, nd1.id).position == 0 35 | assert get_node(cur, nd2.id).position == 1 36 | assert get_node(cur, nd3.id).position == 3 37 | 38 | 39 | def test_find_highest_position(cur, root): 40 | assert find_highest_position(cur, root) == 3 41 | 42 | 43 | def test_find_highest_position_non_existing_node(cur): 44 | assert find_highest_position(cur, str(uuid.uuid4())) == -1 45 | 46 | 47 | def test_shift_positions_to_the_right(cur, root, nd1, nd2, nd3): 48 | shift_positions(cur, root, nd2.position, +1) 49 | assert get_node(cur, nd1.id).position == 0 50 | assert get_node(cur, nd2.id).position == 2 51 | assert get_node(cur, nd3.id).position == 4 52 | 53 | 54 | def test_shift_positions_to_the_left(cur, root, nd1, nd2, nd3): 55 | shift_positions(cur, root, nd2.position, -1) 56 | assert get_node(cur, nd1.id).position == 0 57 | assert get_node(cur, nd2.id).position == 1 58 | assert get_node(cur, nd3.id).position == 3 59 | 60 | 61 | def test_get_node_at_position(cur, root, nd3): 62 | node = get_node_at_position(cur, root, nd3.position) 63 | assert node.position == nd3.position 64 | 65 | 66 | def test_get_node_at_position_non_existing(cur, root, nd3): 67 | with pytest.raises(ValueError): 68 | get_node_at_position(cur, root, -1) 69 | with pytest.raises(ValueError): 70 | get_node_at_position(cur, str(uuid.uuid4()), 1) 71 | 72 | 73 | def test_swap_node_positions(cur, nd1, nd2): 74 | swap_node_positions(cur, nd1, nd2) 75 | assert get_node(cur, nd1.id).position == nd2.position 76 | assert get_node(cur, nd2.id).position == nd1.position 77 | 78 | 79 | def test_insert_node_starts_counting_at_zero(cur, nd1): 80 | nd1_1 = insert_node(cur, nd1, 'nd1-1', auto_position=True) 81 | assert nd1_1.position == 0 82 | 83 | 84 | def test_insert_nodes_at_highest_position(cur, root): 85 | highest_position = find_highest_position(cur, root) 86 | node4 = insert_node(cur, root, position=None, auto_position=True) 87 | node5 = insert_node(cur, root, position=-1, auto_position=True) 88 | assert node4.position == highest_position + 1 89 | assert node5.position == highest_position + 2 90 | 91 | delete_node(cur, node4) 92 | delete_node(cur, node5) 93 | 94 | 95 | def test_ensure_free_position(cur, root): 96 | ensure_free_position(cur, root, 4) 97 | positions = map(lambda n: n.position, get_children(cur, root)) 98 | assert list(positions) == [0, 1, 3] 99 | 100 | 101 | def test_insert_node_at_specific_position(cur, root): 102 | node0 = insert_node(cur, root, position=0, auto_position=True) 103 | positions = map(lambda n: n.position, get_children(cur, root)) 104 | assert node0.position == 0 105 | assert list(positions) == [0, 1, 2, 4] 106 | 107 | 108 | def test_delete_node_shifts_positions(cur, root, nd1): 109 | delete_node(cur, nd1, auto_position=True) 110 | positions = map(lambda n: n.position, get_children(cur, root)) 111 | assert list(positions) == [0, 1, 3] 112 | 113 | 114 | def test_change_parent_to_highest_position(cur, root, nd2, nd2_1): 115 | highest_position = find_highest_position(cur, root) 116 | change_parent(cur, nd2_1, root, position=None, auto_position=True) 117 | nd2_1 = get_node(cur, nd2_1.id) 118 | assert nd2_1.position == highest_position + 1 119 | 120 | 121 | def test_change_parent_starts_couting_at_zero(cur, root, nd2, nd2_1): 122 | change_parent(cur, nd2_1, nd2, position=None, auto_position=True) 123 | nd2_1 = get_node(cur, nd2_1.id) 124 | assert nd2_1.position == 0 125 | 126 | 127 | def test_change_parent_to_specific_position(cur, root, nd2_1): 128 | change_parent(cur, nd2_1, root, position=0, auto_position=True) 129 | positions = map(lambda n: n.position, get_children(cur, root)) 130 | assert list(positions) == [0, 1, 2, 4] 131 | -------------------------------------------------------------------------------- /tests/core/test_properties.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | import pytest 5 | 6 | from libtree.core.properties import (get_inherited_properties, 7 | get_inherited_property_value, 8 | get_nodes_by_property_dict, 9 | get_nodes_by_property_key, 10 | get_nodes_by_property_value, 11 | get_recursive_properties, set_properties, 12 | set_property_value, update_properties) 13 | 14 | 15 | def test_get_nodes_by_property_dict(cur, root): 16 | query = { 17 | 'boolean': False, 18 | 'type': 'root' 19 | } 20 | results = get_nodes_by_property_dict(cur, query) 21 | ids = {child.id for child in results} 22 | assert ids == {root.id} 23 | 24 | 25 | def test_get_nodes_by_property_key(cur, root, nd2, nd2_1_1): 26 | ids = {child.id for child in get_nodes_by_property_key(cur, 'boolean')} 27 | assert root.id in ids 28 | assert nd2.id in ids 29 | assert nd2_1_1.id in ids 30 | 31 | 32 | def test_get_nodes_by_property_value(cur, root, nd2_1_1): 33 | results = get_nodes_by_property_value(cur, 'boolean', False) 34 | ids = {child.id for child in results} 35 | assert root.id in ids 36 | assert nd2_1_1.id in ids 37 | 38 | 39 | def test_get_inherited_property_value(cur, nd2): 40 | assert get_inherited_property_value(cur, nd2, 'integer') == 1 41 | 42 | 43 | def test_get_inherited_properties_no_inheritance(cur, root): 44 | expected = { 45 | 'type': 'root', 46 | 'boolean': False, 47 | 'integer': 1, 48 | 'dict': {'key': 'value'}, 49 | 'list': [{'abc': 2}] 50 | } 51 | assert get_inherited_properties(cur, root) == expected 52 | 53 | 54 | def test_get_inherited_properties_simple_inheritance(cur, nd2): 55 | expected = { 56 | 'title': 'Node 2', 57 | 'type': 'nd2', 58 | 'boolean': True, 59 | 'foo': 'bar', 60 | 'integer': 1, 61 | 'dict': {'another key': 'another value'}, 62 | 'list': [{'abc': 2}] 63 | } 64 | assert get_inherited_properties(cur, nd2.id) == expected 65 | 66 | 67 | def test_get_inherited_properties_multiple_inheritance(cur, nd2_1_1): 68 | expected = { 69 | 'title': 'Node 2-1-1', 70 | 'type': 'nd2_1_1', 71 | 'boolean': False, 72 | 'integer': 1, 73 | 'foo': 'bar', 74 | 'dict': {'key': 'yet another value'}, 75 | 'list': [{'def': 4}] 76 | } 77 | assert get_inherited_properties(cur, nd2_1_1) == expected 78 | 79 | 80 | def test_get_recursive_properties_no_inheritance(cur, root): 81 | expected = { 82 | 'type': 'root', 83 | 'boolean': False, 84 | 'integer': 1, 85 | 'dict': {'key': 'value'}, 86 | 'list': [{'abc': 2}] 87 | } 88 | assert get_recursive_properties(cur, root) == expected 89 | 90 | 91 | def test_get_recursive_properties_simple_inheritance(cur, nd2): 92 | expected = { 93 | 'title': 'Node 2', 94 | 'type': 'nd2', 95 | 'boolean': True, 96 | 'foo': 'bar', 97 | 'integer': 1, 98 | 'dict': {'key': 'value', 'another key': 'another value'}, 99 | 'list': [{'abc': 2}] 100 | } 101 | assert get_recursive_properties(cur, nd2) == expected 102 | 103 | 104 | def test_get_recursive_properties_multiple_inheritance(cur, nd2_1_1): 105 | expected = { 106 | 'title': 'Node 2-1-1', 107 | 'type': 'nd2_1_1', 108 | 'boolean': False, 109 | 'integer': 1, 110 | 'foo': 'bar', 111 | 'dict': {'key': 'yet another value', 'another key': 'another value'}, 112 | 'list': [{'def': 4}] 113 | } 114 | assert get_recursive_properties(cur, str(nd2_1_1)) == expected 115 | 116 | 117 | def test_update_properties(cur, root): 118 | properties = root.properties.copy() 119 | properties['title'] = 'Root node' 120 | properties['new'] = 'property' 121 | root = update_properties(cur, root.id, properties) 122 | 123 | assert root.properties['title'] == 'Root node' 124 | assert root.properties['new'] == 'property' 125 | 126 | 127 | def test_update_properties_only_allows_dict(cur, root): 128 | with pytest.raises(TypeError): 129 | update_properties(cur, root, []) 130 | 131 | 132 | def test_set_properties(cur, root): 133 | properties = {'title': 'My Root Node'} 134 | root = set_properties(cur, root.id, properties) 135 | assert root.properties == properties 136 | 137 | 138 | def test_set_properties_only_allows_dict(cur, root): 139 | with pytest.raises(TypeError): 140 | set_properties(cur, root, []) 141 | 142 | 143 | def test_set_property_value(cur, root): 144 | root = set_property_value(cur, root.id, 'title', 'Root') 145 | assert root.properties['title'] == 'Root' 146 | -------------------------------------------------------------------------------- /tests/core/test_query.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | import uuid 5 | 6 | import pytest 7 | 8 | import libtree 9 | from libtree import exceptions 10 | from libtree.core.query import (get_ancestors, get_child_ids, get_children, 11 | get_children_count, get_descendant_ids, 12 | get_descendants, get_node, get_root_node, 13 | get_tree_size) 14 | 15 | 16 | def test_get_root_node_non_existing(cur): 17 | with pytest.raises(exceptions.NoRootNode): 18 | get_root_node(cur) 19 | 20 | 21 | def test_get_node_non_existing(cur): 22 | with pytest.raises(exceptions.NodeNotFound): 23 | get_node(cur, str(uuid.uuid4())) 24 | 25 | 26 | def test_get_root_node(cur, root, nd1, nd2, nd2_1, nd2_1_1, nd3): 27 | root = get_root_node(cur) 28 | assert root.parent is None 29 | 30 | 31 | def test_get_node(cur, nd1): 32 | node = get_node(cur, nd1.id) 33 | assert node.id == nd1.id 34 | assert node.parent == nd1.parent 35 | 36 | 37 | def test_get_tree_size(cur): 38 | assert get_tree_size(cur) == 6 39 | 40 | 41 | def test_get_node_needs_number(cur, root): 42 | with pytest.raises(TypeError): 43 | get_node(cur, root) 44 | 45 | 46 | def test_get_children(cur, root, nd1, nd2, nd3): 47 | ids = {child.id for child in get_children(cur, root)} 48 | assert len(ids) == 3 49 | assert nd1.id in ids 50 | assert nd2.id in ids 51 | assert nd3.id in ids 52 | 53 | 54 | def test_get_child_ids(cur, root, nd1, nd2, nd3): 55 | ids = set(get_child_ids(cur, root)) 56 | assert len(ids) == 3 57 | assert nd1.id in ids 58 | assert nd2.id in ids 59 | assert nd3.id in ids 60 | 61 | 62 | def test_get_children_correct_positioning(cur, root, nd1, nd2, nd3): 63 | ids = [child.id for child in get_children(cur, root)] 64 | expected = [nd1.id, nd2.id, nd3.id] 65 | assert ids == expected 66 | 67 | 68 | def test_get_child_ids_correct_positioning(cur, root, nd1, nd2, nd3): 69 | ids = list(get_child_ids(cur, root)) 70 | expected = [nd1.id, nd2.id, nd3.id] 71 | assert ids == expected 72 | 73 | 74 | def test_get_children_count(cur, root): 75 | assert get_children_count(cur, root) == 3 76 | 77 | 78 | def test_get_ancestors(cur, root, nd2, nd2_1): 79 | ancestors = list(get_ancestors(cur, nd2_1)) 80 | assert len(ancestors) == 2 81 | assert ancestors[0].id == nd2.id 82 | assert ancestors[1].id == root.id 83 | 84 | 85 | def test_get_ancestors_returns_correct_order(cur, root, nd2, nd2_1, 86 | nd2_1_1, nd2_leaf): 87 | expected = [nd2_1_1.id, nd2_1.id, nd2.id, root.id] 88 | ids = list(map(str, get_ancestors(cur, nd2_leaf, sort=True))) 89 | assert ids == expected 90 | 91 | 92 | def test_get_ancestor_ids(cur, root, nd2, nd2_1, nd2_1_1, nd2_leaf): 93 | expected = {root.id, nd2.id, nd2_1.id, nd2_1_1.id} 94 | ids = set(map(str, get_ancestors(cur, nd2_leaf, sort=False))) 95 | assert ids == expected 96 | 97 | 98 | # TODO: mock always returns False, why? 99 | def xtest_get_ancestors_calls_vectorize_nodes(cur, nd2_leaf): 100 | with patch.object(libtree.tree, 'vectorize_nodes') as func: 101 | get_ancestors(cur, nd2_leaf, sort=True) 102 | assert func.called 103 | 104 | 105 | def test_get_descendant_ids(cur, root, nd1, nd2, nd3, nd2_1, nd2_1_1, 106 | nd2_leaf): 107 | ids = get_descendant_ids(cur, root) 108 | expected_nodes = {nd1, nd2, nd3, nd2_1, nd2_1_1, nd2_leaf} 109 | expected_ids = {node.id for node in expected_nodes} 110 | assert set(ids) == expected_ids 111 | 112 | 113 | def test_get_descendants(cur, root, nd1, nd2, nd3, nd2_1, nd2_1_1, 114 | nd2_leaf): 115 | nodes = get_descendants(cur, root) 116 | ids = {node.id for node in nodes} 117 | expected_nodes = {nd1, nd2, nd3, nd2_1, nd2_1_1, nd2_leaf} 118 | expected_ids = {node.id for node in expected_nodes} 119 | assert ids == expected_ids 120 | 121 | 122 | def xtest_get_inherited_properties(): 123 | raise NotImplementedError 124 | 125 | 126 | def xtest_get_inherited_property(): 127 | raise NotImplementedError 128 | -------------------------------------------------------------------------------- /tests/core/test_tree.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | from pdb import set_trace as trace # noqa 5 | 6 | import pytest 7 | 8 | from libtree import exceptions 9 | from libtree.core.query import (get_ancestor_ids, get_child_ids, get_children, 10 | get_descendant_ids, get_node) 11 | from libtree.core.tree import change_parent, delete_node, insert_node 12 | 13 | 14 | def test_insert_root_node_with_auto_position(cur): 15 | root = insert_node(cur, None, 'folder', auto_position=True) 16 | delete_node(cur, root) 17 | 18 | 19 | def test_insert_node(cur, root, nd1, nd2, nd2_1, nd2_1_1, nd3): 20 | assert root.parent is None 21 | assert nd1.parent == root.id 22 | assert nd2.parent == root.id 23 | assert nd2_1.parent == nd2.id 24 | assert nd2_1_1.parent == nd2_1.id 25 | assert nd3.parent == root.id 26 | 27 | 28 | def test_insert_node_sets_properties(root): 29 | assert root.properties == { 30 | 'type': 'root', 31 | 'boolean': False, 32 | 'integer': 1, 33 | 'dict': {'key': 'value'}, 34 | 'list': [{'abc': 2}] 35 | } 36 | 37 | 38 | def test_insert_node_sets_position(nd1): 39 | assert nd1.position == 4 40 | 41 | 42 | def test_change_parent(cur, root, nd1, nd2, nd2_1, nd2_1_1, 43 | nd2_leaf): 44 | """ 45 | Tree layout before move: 46 | / 47 | - nd1 48 | - nd2 49 | - nd2-1 50 | - nd2-1-1 51 | - nd2-leaf 52 | - nd3 53 | 54 | Expected tree layout after move: 55 | 56 | / 57 | - nd1 58 | - nd2-1 59 | - nd2-1-1 60 | - nd2-leaf 61 | - nd2 62 | - nd3 63 | """ 64 | # We expect nd2-1 to be child of nd2 and nd2-1-1 to be child 65 | # of nd2-1. 66 | 67 | # Move nd2-1 from nd2 to nd1 68 | _temp_node = change_parent(cur, nd2_1.id, nd1, auto_position=False) 69 | 70 | # Return value should have new parent set 71 | assert _temp_node.parent == nd1.id 72 | 73 | # nd2-1 should have nd1 as parent 74 | node = get_node(cur, nd2_1.id) 75 | assert node.parent == nd1.id 76 | 77 | # nd2-1-1 should still have the same parent (nd2-1) 78 | child_node = get_node(cur, nd2_1_1.id) 79 | assert child_node.parent == nd2_1.id 80 | 81 | # nd2-leaf should still have the same parent (nd2-1-1) 82 | child_node = get_node(cur, nd2_leaf.id) 83 | assert child_node.parent == nd2_1_1.id 84 | 85 | # The ancestor set of nd2-1 should now contain nd1 and root 86 | assert set(get_ancestor_ids(cur, nd2_1)) == {root.id, nd1.id} 87 | 88 | # The ancestor set of nd2-1-1 should now contain nd2-1, nd1 and root 89 | expected = {root.id, nd1.id, nd2_1.id} 90 | assert set(get_ancestor_ids(cur, nd2_1_1)) == expected 91 | 92 | # The ancestor set of nd2-leaf should now contain node-2-1-1, nd2-1, 93 | # nd1 and root 94 | expected = {root.id, nd1.id, nd2_1.id, nd2_1_1.id} 95 | assert set(get_ancestor_ids(cur, nd2_leaf)) == expected 96 | 97 | # The ancestor set of nd2 should now only contain root 98 | assert set(get_ancestor_ids(cur, nd2)) == {root.id} 99 | 100 | # Check if nd2-1, nd2-1-1 and nd2-leaf are part of nd1's descendant 101 | # set now 102 | expected = {nd2_1.id, nd2_1_1.id, nd2_leaf.id} 103 | assert set(get_descendant_ids(cur, nd1)) == expected 104 | 105 | # nd2's descendant set should be empty now 106 | assert set(get_descendant_ids(cur, nd2)) == set() 107 | 108 | # Last but not least, the children function proof what we checked above too 109 | assert len(set(get_children(cur, nd1))) == 1 110 | assert len(set(get_children(cur, nd2))) == 0 111 | 112 | 113 | def test_change_parent_dont_move_into_own_subtree(cur, nd1, nd2_1): 114 | with pytest.raises(exceptions.CantMoveIntoOwnSubtree): 115 | change_parent(cur, nd1, nd2_1) 116 | 117 | 118 | def test_delete_node(cur, nd1, nd2_1, nd2_1_1, nd2_leaf): 119 | """ 120 | Tree layout before delete: 121 | / 122 | - nd1 123 | - nd2-1 124 | - nd2-1-1 125 | - nd2-leaf 126 | - nd2 127 | - nd3 128 | 129 | Expected tree layout after move: 130 | / 131 | - nd1 132 | - nd2-1 133 | - nd2 134 | - nd3 135 | """ 136 | delete_node(cur, nd2_1_1, auto_position=False) 137 | 138 | # Deleted node doesn't exist anymore 139 | with pytest.raises(exceptions.NodeNotFound): 140 | get_node(cur, nd2_1_1.id) 141 | 142 | # nd2-1 has no children and no descendants 143 | assert set(get_child_ids(cur, nd2_1)) == set() 144 | assert set(get_child_ids(cur, nd2_1_1)) == set() 145 | assert set(get_descendant_ids(cur, nd2_1)) == set() 146 | 147 | # nd1 just contains nd2-1 148 | assert set(get_child_ids(cur, nd1)) == {nd2_1.id} 149 | assert set(get_descendant_ids(cur, nd1)) == {nd2_1.id} 150 | 151 | # Ancestor and descendant sets of nd2-1-1 and nd2-leaf are empty 152 | # (or raise error in the future because they don't exist anymore) 153 | assert set(get_ancestor_ids(cur, nd2_1_1)) == set() 154 | assert set(get_ancestor_ids(cur, nd2_leaf)) == set() 155 | assert set(get_descendant_ids(cur, nd2_1_1)) == set() 156 | assert set(get_descendant_ids(cur, nd2_leaf)) == set() 157 | 158 | 159 | def test_delete_node_by_id(cur, nd1, nd2_1): 160 | delete_node(cur, nd2_1.id, auto_position=True) 161 | 162 | assert set(get_child_ids(cur, nd1)) == set() 163 | -------------------------------------------------------------------------------- /tests/test_node.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | from libtree import core, Node 5 | from mock import patch 6 | 7 | 8 | def test_basic_representation(trans, root): 9 | node = Node(trans, root.id) 10 | assert repr(node) == '<Node id=\'{}\'>'.format(root.id, root.position) 11 | 12 | 13 | def test_title_representation(trans, nd1): 14 | node = Node(trans, nd1.id) 15 | expected = "<Node id=\'{}\', title='Node 1'>".format(nd1.id) 16 | assert repr(node) == expected 17 | 18 | 19 | def test_it_compares_other_nodes(trans, nd1, nd2): 20 | node1a = Node(trans, nd1.id) 21 | node1b = Node(trans, nd1.id) 22 | node2 = Node(trans, nd2.id) 23 | assert node1a == node1b 24 | assert node1a != node2 25 | 26 | 27 | def test_it_wont_compare_other_types(trans, nd1, nd2): 28 | node = Node(trans, nd1.id) 29 | assert node != nd1 30 | 31 | 32 | def test_hash(trans, nd1, nd2): 33 | node1a = Node(trans, nd1.id) 34 | node1b = Node(trans, nd1.id) 35 | node2 = Node(trans, nd2.id) 36 | assert hash(node1a) == hash(node1b) 37 | assert hash(node1a) != hash(node2) 38 | 39 | 40 | def test_get_parent(trans, nd2_1_1, nd2_leaf): 41 | node = Node(trans, nd2_leaf.id) 42 | parent = node.parent 43 | assert parent.id == nd2_1_1.id 44 | 45 | 46 | def test_get_parent_returns_none_for_root(trans, root): 47 | assert Node(trans, root.id).parent is None 48 | 49 | 50 | def test_get_position(trans, nd3): 51 | node = Node(trans, nd3.id) 52 | assert node.position == nd3.position 53 | 54 | 55 | def test_get_properties(trans, nd3): 56 | node = Node(trans, nd3.id) 57 | assert node.properties == nd3.properties 58 | 59 | 60 | def test_get_children_count(trans, cur, root): 61 | node = Node(trans, root) 62 | assert len(node) == core.get_children_count(cur, root.id) 63 | 64 | 65 | def test_get_children(trans, nd2, nd2_1): 66 | node = Node(trans, nd2.id) 67 | children = [Node(trans, nd2_1.id)] 68 | assert node.children == children 69 | 70 | 71 | def test_has_children(trans, nd2, nd2_leaf): 72 | node = Node(trans, nd2.id) 73 | node2_leaf = Node(trans, nd2_leaf.id) 74 | assert node.has_children 75 | assert not node2_leaf.has_children 76 | 77 | 78 | def test_get_ancestors_bottom_up(trans, root, nd2, nd2_1): 79 | node = Node(trans, nd2_1.id) 80 | ancestors = [Node(trans, nd2.id), Node(trans, root.id)] 81 | assert node.ancestors == ancestors 82 | 83 | 84 | def test_get_descendants(trans, nd2_1, nd2_1_1, nd2_leaf): 85 | node = Node(trans, nd2_1.id) 86 | descendants = {Node(trans, nd2_1_1.id), Node(trans, nd2_leaf.id)} 87 | assert node.descendants == descendants 88 | 89 | 90 | @patch.object(core, 'insert_node') 91 | def test_insert_child(mock, trans, cur): 92 | node = Node(trans, 1) 93 | properties = {'type': 'new_child'} 94 | node.insert_child(properties) 95 | mock.assert_called_with(cur, node.id, properties, position=-1, 96 | auto_position=True, id=None) 97 | 98 | 99 | @patch.object(core, 'delete_node') 100 | def test_delete(mock, trans, cur): 101 | node = Node(trans, 1) 102 | node.delete() 103 | mock.assert_called_with(cur, node.id) 104 | 105 | 106 | @patch.object(core, 'change_parent') 107 | def test_move(mock, trans, cur): 108 | node = Node(trans, 1) 109 | target_node = Node(trans, 2) 110 | node.move(target_node) 111 | mock.assert_called_with(cur, node.id, target_node.id, position=-1, 112 | auto_position=True) 113 | 114 | 115 | @patch.object(core, 'swap_node_positions') 116 | def test_swap_position(mock, trans, cur): 117 | node1 = Node(trans, 1) 118 | node2 = Node(trans, 2) 119 | node1.swap_position(node2) 120 | mock.assert_called_with(cur, node1.id, node2.id) 121 | 122 | 123 | @patch.object(core, 'get_inherited_properties') 124 | def test_get_inherited_properties(mock, trans, cur): 125 | node = Node(trans, 1) 126 | node.inherited_properties 127 | mock.assert_called_with(cur, node.id) 128 | 129 | 130 | @patch.object(core, 'get_recursive_properties') 131 | def test_get_recursive_properties(mock, trans, cur): 132 | node = Node(trans, 1) 133 | node.recursive_properties 134 | mock.assert_called_with(cur, node.id) 135 | 136 | 137 | @patch.object(core, 'set_properties') 138 | def test_set_properties(mock, trans, cur): 139 | node = Node(trans, 1) 140 | node.set_properties({'foo': 'bar'}) 141 | mock.assert_called_with(cur, node.id, {'foo': 'bar'}) 142 | 143 | 144 | @patch.object(core, 'update_properties') 145 | def test_update_properties(mock, trans, cur): 146 | node = Node(trans, 1) 147 | node.update_properties({'foo': 'bar'}) 148 | mock.assert_called_with(cur, node.id, {'foo': 'bar'}) 149 | 150 | 151 | @patch.object(core, 'set_position') 152 | def test_set_position(mock, trans, cur): 153 | node = Node(trans, 1) 154 | node.set_position(1337) 155 | mock.assert_called_with(cur, node.id, 1337, auto_position=True) 156 | 157 | 158 | @patch.object(core, 'get_node_at_position') 159 | def test_get_child_at_position(mock, trans, cur): 160 | node = Node(trans, 1) 161 | node.get_child_at_position(2) 162 | mock.assert_called_with(cur, 1, 2) 163 | -------------------------------------------------------------------------------- /tests/test_transactions.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | from libtree import core, ReadOnlyTransaction, ReadWriteTransaction 5 | from mock import Mock, patch 6 | 7 | 8 | def test_it_takes_a_connection(): 9 | conn = Mock() 10 | assert ReadWriteTransaction(conn, Mock()).connection is conn 11 | 12 | 13 | def test_it_sets_read_only_mode(): 14 | conn = Mock() 15 | ReadOnlyTransaction(conn, Mock()) 16 | conn.set_session.assert_called_with(readonly=True) 17 | 18 | 19 | def test_it_sets_read_write_mode(): 20 | conn = Mock() 21 | ReadWriteTransaction(conn, Mock()) 22 | conn.set_session.assert_called_with(readonly=False) 23 | 24 | 25 | def test_it_creates_a_cursor(): 26 | conn = Mock() 27 | ReadWriteTransaction(conn, Mock()) 28 | assert conn.cursor.called 29 | 30 | 31 | def test_commit(): 32 | conn = Mock() 33 | ReadWriteTransaction(conn, Mock()).commit() 34 | assert conn.commit.called 35 | 36 | 37 | def test_rollback(): 38 | conn = Mock() 39 | ReadWriteTransaction(conn, Mock()).rollback() 40 | assert conn.rollback.called 41 | 42 | 43 | @patch.object(core, 'is_compatible_postgres_version') 44 | def test_is_compatible_postgres_version(mock): 45 | transaction = ReadWriteTransaction(Mock(), Mock()) 46 | transaction.is_compatible_postgres_version() 47 | mock.assert_called_with(transaction.cursor) 48 | 49 | 50 | @patch.object(core, 'is_installed', return_value=False) 51 | @patch.object(core, 'create_schema') 52 | @patch.object(core, 'create_triggers') 53 | def test_install(mock1, mock2, mock3): 54 | transaction = ReadWriteTransaction(Mock(), Mock()) 55 | transaction.install() 56 | mock1.assert_called_with(transaction.cursor) 57 | mock2.assert_called_with(transaction.cursor) 58 | mock3.assert_called() 59 | 60 | 61 | @patch.object(core, 'is_installed', return_value=True) 62 | @patch.object(core, 'create_schema') 63 | @patch.object(core, 'create_triggers') 64 | def test_install_already_installed(mock1, mock2, mock3): 65 | transaction = ReadWriteTransaction(Mock(), Mock()) 66 | transaction.install() 67 | 68 | assert mock1.not_called() 69 | assert mock2.not_called() 70 | mock3.assert_called() 71 | 72 | 73 | @patch.object(core, 'drop_tables') 74 | def test_uninstall(mock): 75 | transaction = ReadWriteTransaction(Mock(), Mock()) 76 | transaction.uninstall() 77 | mock.assert_called_with(transaction.cursor) 78 | 79 | 80 | @patch.object(core, 'flush_tables') 81 | def test_clear(mock): 82 | transaction = ReadWriteTransaction(Mock(), Mock()) 83 | transaction.clear() 84 | mock.assert_called_with(transaction.cursor) 85 | 86 | 87 | @patch.object(core, 'print_tree') 88 | def test_print_tree(mock): 89 | transaction = ReadWriteTransaction(Mock(), Mock()) 90 | transaction.print_tree() 91 | mock.assert_called_with(transaction.cursor) 92 | 93 | 94 | @patch.object(core, 'get_tree_size') 95 | def test_get_tree_size(mock): 96 | transaction = ReadWriteTransaction(Mock(), Mock()) 97 | transaction.get_tree_size() 98 | mock.assert_called_with(transaction.cursor) 99 | 100 | 101 | @patch.object(core, 'get_root_node') 102 | def test_get_root_node(mock): 103 | transaction = ReadWriteTransaction(Mock(), Mock()) 104 | transaction.get_root_node() 105 | mock.assert_called_with(transaction.cursor) 106 | 107 | 108 | @patch.object(core, 'insert_node') 109 | def test_insert_root_node(mock): 110 | transaction = ReadWriteTransaction(Mock(), Mock()) 111 | transaction.insert_root_node() 112 | mock.assert_called_with(transaction.cursor, None, None) 113 | 114 | 115 | @patch.object(core, 'get_node') 116 | def test_get_node(mock): 117 | transaction = ReadWriteTransaction(Mock(), Mock()) 118 | transaction.get_node(1337) 119 | mock.assert_called_with(transaction.cursor, 1337) 120 | 121 | 122 | @patch.object(core, 'get_nodes_by_property_dict') 123 | def test_get_nodes_by_property_dict(mock): 124 | mock.return_value = [Mock(id=1), Mock(id=2)] 125 | transaction = ReadWriteTransaction(Mock(), Mock()) 126 | query = {'key': 'value'} 127 | transaction.get_nodes_by_property_dict(query) 128 | mock.assert_called_with(transaction.cursor, query) 129 | 130 | 131 | @patch.object(core, 'get_nodes_by_property_key') 132 | def test_get_nodes_by_property_key(mock): 133 | mock.return_value = [Mock(id=1), Mock(id=2)] 134 | transaction = ReadWriteTransaction(Mock(), Mock()) 135 | transaction.get_nodes_by_property_key('foobar') 136 | mock.assert_called_with(transaction.cursor, 'foobar') 137 | 138 | 139 | @patch.object(core, 'get_nodes_by_property_value') 140 | def test_get_nodes_by_property_value(mock): 141 | mock.return_value = [Mock(id=1), Mock(id=2)] 142 | transaction = ReadWriteTransaction(Mock(), Mock()) 143 | transaction.get_nodes_by_property_value('key', 'value') 144 | mock.assert_called_with(transaction.cursor, 'key', 'value') 145 | -------------------------------------------------------------------------------- /tests/test_tree.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | from libtree import ReadOnlyTransaction, ReadWriteTransaction, Tree 5 | from mock import Mock 6 | import pytest 7 | 8 | 9 | def test_it_takes_a_connection(): 10 | conn = Mock() 11 | assert Tree(connection=conn).connection is conn 12 | 13 | 14 | def test_it_takes_a_connection_pool(): 15 | pool = Mock() 16 | assert Tree(pool=pool).pool is pool 17 | 18 | 19 | def test_it_requires_either_connection_or_pool(): 20 | with pytest.raises(TypeError): 21 | Tree() 22 | 23 | 24 | def test_it_cant_deal_with_both_connection_and_pool(): 25 | with pytest.raises(TypeError): 26 | Tree(connection=Mock(), pool=Mock()) 27 | 28 | 29 | def test_get_connection_returns_the_assigned_one(): 30 | conn = Mock() 31 | tree = Tree(connection=conn) 32 | assert tree.get_connection() is conn 33 | 34 | 35 | def test_get_connection_returns_connection_from_pool(): 36 | pool, conn = Mock(), Mock() 37 | pool.getconn.return_value = conn 38 | tree = Tree(pool=pool) 39 | assert tree.get_connection() is conn 40 | assert pool.getconn.called 41 | 42 | 43 | def test_make_transaction_returns_a_read_only_transaction_object(): 44 | conn = Mock() 45 | tree = Tree(connection=conn) 46 | transaction = tree.make_transaction() 47 | assert transaction.__class__ == ReadOnlyTransaction 48 | 49 | 50 | def test_make_transaction_returns_a_read_write_transaction_object(): 51 | conn = Mock() 52 | tree = Tree(connection=conn) 53 | transaction = tree.make_transaction(write=True) 54 | assert transaction.__class__ == ReadWriteTransaction 55 | 56 | 57 | def test_returned_transaction_uses_assigned_transaction_object(): 58 | conn = Mock() 59 | tree = Tree(connection=conn) 60 | transaction = tree.make_transaction() 61 | assert transaction.connection is conn 62 | 63 | 64 | def test_returned_transaction_uses_connection_from_pool(): 65 | pool, conn = Mock(), Mock() 66 | pool.getconn.return_value = conn 67 | tree = Tree(pool=pool) 68 | transaction = tree.make_transaction() 69 | assert transaction.connection is conn 70 | 71 | 72 | def test_cm_puts_connection_back_into_pool(): 73 | pool, conn = Mock(), Mock() 74 | pool.getconn.return_value = conn 75 | tree = Tree(pool=pool) 76 | with tree() as transaction: # noqa 77 | pass 78 | assert pool.putconn.called 79 | pool.putconn.assert_called_with(conn) 80 | 81 | 82 | def test_cm_commits_transaction(): 83 | conn = Mock() 84 | tree = Tree(connection=conn) 85 | with tree() as transaction: # noqa 86 | pass 87 | assert conn.commit.called 88 | 89 | 90 | def test_cm_rolls_back_transaction(): 91 | conn = Mock() 92 | tree = Tree(connection=conn) 93 | with pytest.raises(ValueError): 94 | with tree() as transaction: # noqa 95 | raise ValueError() 96 | assert conn.rollback.called 97 | 98 | 99 | def test_close_connection(): 100 | conn = Mock() 101 | Tree(connection=conn).close() 102 | assert conn.close.called 103 | 104 | 105 | def test_close_pool(): 106 | pool = Mock() 107 | Tree(pool=pool).close() 108 | assert pool.closeall.called 109 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Fabian Kochem 2 | 3 | 4 | from libtree.utils import recursive_dict_merge, vectorize_nodes 5 | 6 | 7 | def test_recursive_dict_merge(): 8 | left = { 9 | 1: {'a': 'A'}, 10 | 2: {'b': 'B'}, 11 | 3: {'c': {'c': 'C'}} 12 | } 13 | right = { 14 | 2: {'b': 'b', 'c': 'C'}, 15 | 3: {'c': {'c': 'c'}, 'd': 'D'} 16 | } 17 | expected = { 18 | 1: {'a': 'A'}, 19 | 2: {'b': 'b', 'c': 'C'}, 20 | 3: {'c': {'c': 'c'}, 'd': 'D'} 21 | } 22 | 23 | assert recursive_dict_merge(left, right) == expected 24 | 25 | 26 | def test_vectorize_nodes(cur, root, nd2, nd2_1, nd2_1_1): 27 | nodes = [nd2_1_1, nd2, root, nd2_1] 28 | expected = [root, nd2, nd2_1, nd2_1_1] 29 | assert vectorize_nodes(nodes) == expected 30 | assert vectorize_nodes(*nodes) == expected 31 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py35,pypy3 3 | 4 | [testenv] 5 | commands = 6 | mkdir -p junit 7 | make style-verbose 8 | py.test -v --junitxml=junit/pytest-{envname}.xml --cov-report term-missing --cov-report xml --cov {envsitepackagesdir}/libtree tests 9 | deps = 10 | dont-fudge-up 11 | flake8 12 | mock 13 | psycopg2==2.6.1 14 | pytest 15 | pytest-cov 16 | passenv = 17 | PGHOST 18 | PGPORT 19 | PGUSER 20 | PGPASSWORD 21 | PGDATABASE 22 | 23 | whitelist_externals = 24 | make 25 | mkdir 26 | 27 | [testenv:docs] 28 | deps = sphinx 29 | commands = make docs 30 | whitelist_externals = make 31 | --------------------------------------------------------------------------------