├── .gitignore ├── .isort.cfg ├── .travis.yml ├── CHANGES.rst ├── LICENSE ├── README.rst ├── docs ├── Makefile ├── api.rst ├── compound_documents.rst ├── conf.py ├── fields.rst ├── filtering.rst ├── index.rst ├── installation.rst ├── make.bat ├── quickstart.rst ├── sorting.rst └── type_formatting.rst ├── setup.py ├── sqlalchemy_json_api ├── __init__.py ├── exc.py ├── hybrids.py ├── query_builder.py └── utils.py └── tests ├── __init__.py ├── conftest.py ├── test_select.py ├── test_select_one.py ├── test_select_related.py ├── test_select_relationship.py ├── test_select_with_include.py ├── test_select_with_links.py ├── test_select_with_sort.py ├── test_type_formatters.py └── test_utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | line_length=79 3 | multi_line_output=3 4 | not_skip=__init__.py 5 | order_by_type=false 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | addons: 2 | postgresql: "9.4" 3 | 4 | before_script: 5 | - psql -c 'create database sqlalchemy_json_api_test;' -U postgres 6 | 7 | language: python 8 | python: 9 | - 2.7 10 | - 3.4 11 | - 3.5 12 | - 3.6 13 | env: 14 | matrix: 15 | - SQLALCHEMY=SQLAlchemy>=1.0,<1.1 16 | - SQLALCHEMY=SQLAlchemy>=1.1,<1.2 17 | - SQLALCHEMY=SQLAlchemy>=1.2,<1.3 18 | - SQLALCHEMY=SQLAlchemy>=1.3 19 | 20 | install: 21 | - "pip install $SQLALCHEMY" 22 | - pip install -e .[test] 23 | 24 | script: 25 | - isort --recursive --diff sqlalchemy_json_api tests && isort --recursive --check-only sqlalchemy_json_api tests 26 | - flake8 sqlalchemy_json_api tests 27 | - py.test tests 28 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | --------- 3 | 4 | Here you can see the full list of changes between each SQLAlchemy-JSON-API release. 5 | 6 | 7 | 0.4.7 (2018-12-03) 8 | ^^^^^^^^^^^^^^^^^^ 9 | 10 | - Fixed slow includes (#17) 11 | 12 | 13 | 0.4.6 (2018-01-02) 14 | ^^^^^^^^^^^^^^^^^^ 15 | 16 | - Added support for Hybrid Value object pattern. In other words hybrid properties returning comparator classes (#14). 17 | 18 | 19 | 0.4.5 (2017-07-28) 20 | ^^^^^^^^^^^^^^^^^^ 21 | 22 | - Fixed intermediate table aliasing with custom relationship property order by 23 | - Updated SQLAlchemy-Utils dependency to 0.32.19 24 | 25 | 26 | 0.4.4 (2017-07-28) 27 | ^^^^^^^^^^^^^^^^^^ 28 | 29 | - Fixed hybrid property inspection for from_obj 30 | 31 | 32 | 0.4.3 (2017-07-28) 33 | ^^^^^^^^^^^^^^^^^^ 34 | 35 | - Fixed column adaptation for hybrid / column properties returning Column objects 36 | 37 | 38 | 0.4.2 (2017-03-17) 39 | ^^^^^^^^^^^^^^^^^^ 40 | 41 | - Fixed SQLAlchemy warnings 42 | - Made query builder use CTEs for better performance and simpler queries 43 | - Smarter limit and offset 44 | - Added sort_included parameter for QueryBuilder 45 | 46 | 47 | 0.4.1 (2017-01-06) 48 | ^^^^^^^^^^^^^^^^^^ 49 | 50 | - Fixed unambiguous column reference passed for relationship order by (#10) 51 | - Dropped python 2.6 support 52 | - Added python 3.5 to test matrix 53 | - Added SQLAlchemy 1.1 to test matrix 54 | 55 | 56 | 0.4.0 (2016-06-06) 57 | ^^^^^^^^^^^^^^^^^^ 58 | 59 | - Added type formatting (#5) 60 | - Added limit and offset parameters (#6) 61 | - Fixed passing empty array as `included`` parameter (#9) 62 | - Default order by for relationships in order to force deterministic results (#10) 63 | - Added column property adaptation in similar manner as hybrid properties are adapted (#11) 64 | 65 | 66 | 0.3.0 (2015-08-18) 67 | ^^^^^^^^^^^^^^^^^^ 68 | 69 | - Made select_one return ``None`` when main data equals null 70 | 71 | 72 | 0.2.2 (2015-08-16) 73 | ^^^^^^^^^^^^^^^^^^ 74 | 75 | - Fixed included to use distinct for included resources queries 76 | 77 | 78 | 0.2.1 (2015-08-16) 79 | ^^^^^^^^^^^^^^^^^^ 80 | 81 | - Fixed included parameter when fetching multiple included objects for multiple root resources 82 | - Update SA-Utils dependency to 0.30.17 83 | 84 | 85 | 0.2 (2015-08-11) 86 | ^^^^^^^^^^^^^^^^ 87 | 88 | - Added sort parameter to select method 89 | - Added support for links objects 90 | - Added reserved keyword checking 91 | - Added select_related and select_relationship methods 92 | - Added as_text parameter to all select_* methods 93 | 94 | 95 | 0.1 (2015-07-29) 96 | ^^^^^^^^^^^^^^^^ 97 | 98 | - Initial release 99 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Konsta Vesterinen 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of sqlalchemy-json-api nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | SQLAlchemy-JSON-API 2 | =================== 3 | 4 | Fast `SQLAlchemy`_ query builder for returning `JSON API`_ compatible results. Currently supports only `PostgreSQL`_. 5 | 6 | Why? 7 | ---- 8 | 9 | Speed is essential for JSON APIs. Fetching objects in `SQLAlchemy`_ and serializing them 10 | on Python server is an order of magnitude slower than returning JSON directly from database. This is because 11 | 12 | 1. Complex object structures are hard or impossible to fetch with single query when the serialization happens on Python side. Any kind of JSON API compatible object structure can be returned with a single query from database. 13 | 2. SQLAlchemy objects are memory hungry. 14 | 3. Rather than returning the data directly as JSON from the database it has to be first converted to Python data types and then serialized back to JSON. 15 | 16 | By following this logic it would seem like a no-brainer to return the JSON directly from the database. However the queries are very hard to write. Luckily this is where SQLAlchemy-JSON-API comes to rescue the day. So instead of writing something like this: 17 | 18 | .. code-block:: sql 19 | 20 | SELECT row_to_json(main_json_query.*) 21 | FROM ( 22 | SELECT ( 23 | SELECT coalesce( 24 | array_agg(data_query.data), 25 | CAST(ARRAY[] AS JSON[]) 26 | ) AS data 27 | FROM ( 28 | SELECT 29 | json_build_object( 30 | 'id', 31 | CAST(article.id AS VARCHAR), 32 | 'type', 33 | 'articles', 34 | 'attributes', 35 | json_build_object( 36 | 'name', 37 | article.name 38 | ), 39 | 'relationships', 40 | json_build_object( 41 | 'comments', 42 | json_build_object( 43 | 'data', 44 | ( 45 | SELECT 46 | coalesce( 47 | array_agg(relationships.json_object), 48 | CAST(ARRAY[] AS JSON[]) 49 | ) AS coalesce_2 50 | FROM ( 51 | SELECT json_build_object( 52 | 'id', 53 | CAST(comment.id AS VARCHAR), 54 | 'type', 55 | 'comments' 56 | ) AS json_object 57 | FROM comment 58 | WHERE article.id = comment.article_id 59 | ) AS relationships 60 | ) 61 | ) 62 | ) 63 | ) AS data 64 | FROM article 65 | ) AS data_query 66 | ) AS data 67 | ) AS main_json_query 68 | 69 | 70 | You can simply write: 71 | 72 | .. code-block:: python 73 | 74 | 75 | from sqlalchemy_json_api import QueryBuilder 76 | 77 | 78 | query_builder = QueryBuilder({'articles': Article, 'comments': Comment}) 79 | query_builder.select(Article, {'articles': ['name', 'comments']}) 80 | result = session.execute(query).scalar() 81 | 82 | 83 | To get results such as: 84 | 85 | .. code-block:: python 86 | 87 | 88 | { 89 | 'data': [{ 90 | 'id': '1', 91 | 'type': 'articles', 92 | 'attributes': { 93 | 'content': 'Some content', 94 | 'name': 'Some article', 95 | }, 96 | 'relationships': { 97 | 'comments': { 98 | 'data': [ 99 | {'id': '1', 'type': 'comments'}, 100 | {'id': '2', 'type': 'comments'} 101 | ] 102 | }, 103 | }, 104 | }], 105 | } 106 | 107 | 108 | .. image:: https://c1.staticflickr.com/1/56/188370562_8fe0f3cba9.jpg 109 | 110 | 111 | .. _SQLAlchemy: http://www.sqlalchemy.org 112 | .. _PostgreSQL: http://www.postgresql.org 113 | .. _`JSON API`: http://jsonapi.org 114 | -------------------------------------------------------------------------------- /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) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 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/SQLAlchemy-JSON-API.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/SQLAlchemy-JSON-API.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/SQLAlchemy-JSON-API" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/SQLAlchemy-JSON-API" 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/api.rst: -------------------------------------------------------------------------------- 1 | API Documentation 2 | ----------------- 3 | 4 | This part of the documentation covers all the public classes and functions 5 | in SQLAlchemy-JSON-API. 6 | 7 | 8 | .. module:: sqlalchemy_json_api 9 | .. autoclass:: QueryBuilder 10 | :members: 11 | 12 | .. exception:: IdPropertyNotFound 13 | .. exception:: InvalidField 14 | .. exception:: UnknownField 15 | .. exception:: UnknownModel 16 | .. exception:: UnknownFieldKey 17 | -------------------------------------------------------------------------------- /docs/compound_documents.rst: -------------------------------------------------------------------------------- 1 | Compound documents 2 | ------------------ 3 | 4 | You can create queries returning `compound document responses`_ by providing the ``include`` parameter to :meth:`.QueryBuilder.select`. 5 | 6 | 7 | .. _`compound document responses`: http://jsonapi.org/format/#document-compound-documents 8 | 9 | :: 10 | 11 | 12 | query = query_builder.select( 13 | Article, 14 | fields={'articles': ['name', 'comments']}, 15 | include=['comments'] 16 | ) 17 | result = session.execute(query).scalar() 18 | # { 19 | # 'data': [{ 20 | # 'id': '1', 21 | # 'type': 'articles', 22 | # 'attributes': { 23 | # 'content': 'Some content', 24 | # 'name': 'Some article', 25 | # }, 26 | # 'relationships': { 27 | # 'comments': { 28 | # 'data': [ 29 | # {'id': '1', 'type': 'comments'}, 30 | # {'id': '2', 'type': 'comments'} 31 | # ] 32 | # }, 33 | # }, 34 | # }], 35 | # 'included': [ 36 | # { 37 | # 'id': '1', 38 | # 'type': 'comments', 39 | # 'attributes': { 40 | # 'content': 'Some comment' 41 | # } 42 | # }, 43 | # { 44 | # 'id': '2', 45 | # 'type': 'comments', 46 | # 'attributes': { 47 | # 'content': 'Some other comment' 48 | # } 49 | # } 50 | # ] 51 | # } 52 | 53 | 54 | .. note:: 55 | 56 | SQLAlchemy-JSON-API always returns all included resources ordered by first 57 | type and then by id in ascending order. The consistent order of resources 58 | helps testing APIs. 59 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # SQLAlchemy-JSON-API documentation build configuration file, created by 5 | # sphinx-quickstart on Tue Jul 21 09:56:34 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 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | sys.path.insert(0, os.path.abspath('..')) 23 | from sqlalchemy_json_api import __version__ 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.doctest', 36 | 'sphinx.ext.intersphinx', 37 | 'sphinx.ext.todo', 38 | 'sphinx.ext.coverage', 39 | 'sphinx.ext.viewcode', 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # The suffix(es) of source filenames. 46 | # You can specify multiple suffix as a list of string: 47 | # source_suffix = ['.rst', '.md'] 48 | source_suffix = '.rst' 49 | 50 | # The encoding of source files. 51 | #source_encoding = 'utf-8-sig' 52 | 53 | # The master toctree document. 54 | master_doc = 'index' 55 | 56 | # General information about the project. 57 | project = 'SQLAlchemy-JSON-API' 58 | copyright = '2015, Konsta Vesterinen' 59 | author = 'Konsta Vesterinen' 60 | 61 | # The version info for the project you're documenting, acts as replacement for 62 | # |version| and |release|, also used in various other places throughout the 63 | # built documents. 64 | # 65 | # The short X.Y version. 66 | version = __version__ 67 | # The full version, including alpha/beta/rc tags. 68 | release = version 69 | 70 | # The language for content autogenerated by Sphinx. Refer to documentation 71 | # for a list of supported languages. 72 | # 73 | # This is also used if you do content translation via gettext catalogs. 74 | # Usually you set "language" from the command line for these cases. 75 | language = None 76 | 77 | # There are two options for replacing |today|: either, you set today to some 78 | # non-false value, then it is used: 79 | #today = '' 80 | # Else, today_fmt is used as the format for a strftime call. 81 | #today_fmt = '%B %d, %Y' 82 | 83 | # List of patterns, relative to source directory, that match files and 84 | # directories to ignore when looking for source files. 85 | exclude_patterns = ['_build'] 86 | 87 | # The reST default role (used for this markup: `text`) to use for all 88 | # documents. 89 | #default_role = None 90 | 91 | # If true, '()' will be appended to :func: etc. cross-reference text. 92 | #add_function_parentheses = True 93 | 94 | # If true, the current module name will be prepended to all description 95 | # unit titles (such as .. function::). 96 | #add_module_names = True 97 | 98 | # If true, sectionauthor and moduleauthor directives will be shown in the 99 | # output. They are ignored by default. 100 | #show_authors = False 101 | 102 | # The name of the Pygments (syntax highlighting) style to use. 103 | pygments_style = 'sphinx' 104 | 105 | # A list of ignored prefixes for module index sorting. 106 | #modindex_common_prefix = [] 107 | 108 | # If true, keep warnings as "system message" paragraphs in the built documents. 109 | #keep_warnings = False 110 | 111 | # If true, `todo` and `todoList` produce output, else they produce nothing. 112 | todo_include_todos = True 113 | 114 | 115 | # -- Options for HTML output ---------------------------------------------- 116 | 117 | # The theme to use for HTML and HTML Help pages. See the documentation for 118 | # a list of builtin themes. 119 | html_theme = 'sphinx_rtd_theme' 120 | 121 | # Theme options are theme-specific and customize the look and feel of a theme 122 | # further. For a list of options available for each theme, see the 123 | # documentation. 124 | #html_theme_options = {} 125 | 126 | # Add any paths that contain custom themes here, relative to this directory. 127 | #html_theme_path = [] 128 | 129 | # The name for this set of Sphinx documents. If None, it defaults to 130 | # " v documentation". 131 | #html_title = None 132 | 133 | # A shorter title for the navigation bar. Default is the same as html_title. 134 | #html_short_title = None 135 | 136 | # The name of an image file (relative to this directory) to place at the top 137 | # of the sidebar. 138 | #html_logo = None 139 | 140 | # The name of an image file (within the static path) to use as favicon of the 141 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 142 | # pixels large. 143 | #html_favicon = None 144 | 145 | # Add any paths that contain custom static files (such as style sheets) here, 146 | # relative to this directory. They are copied after the builtin static files, 147 | # so a file named "default.css" will overwrite the builtin "default.css". 148 | html_static_path = ['_static'] 149 | 150 | # Add any extra paths that contain custom files (such as robots.txt or 151 | # .htaccess) here, relative to this directory. These files are copied 152 | # directly to the root of the documentation. 153 | #html_extra_path = [] 154 | 155 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 156 | # using the given strftime format. 157 | #html_last_updated_fmt = '%b %d, %Y' 158 | 159 | # If true, SmartyPants will be used to convert quotes and dashes to 160 | # typographically correct entities. 161 | #html_use_smartypants = True 162 | 163 | # Custom sidebar templates, maps document names to template names. 164 | #html_sidebars = {} 165 | 166 | # Additional templates that should be rendered to pages, maps page names to 167 | # template names. 168 | #html_additional_pages = {} 169 | 170 | # If false, no module index is generated. 171 | #html_domain_indices = True 172 | 173 | # If false, no index is generated. 174 | #html_use_index = True 175 | 176 | # If true, the index is split into individual pages for each letter. 177 | #html_split_index = False 178 | 179 | # If true, links to the reST sources are added to the pages. 180 | #html_show_sourcelink = True 181 | 182 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 183 | #html_show_sphinx = True 184 | 185 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 186 | #html_show_copyright = True 187 | 188 | # If true, an OpenSearch description file will be output, and all pages will 189 | # contain a tag referring to it. The value of this option must be the 190 | # base URL from which the finished HTML is served. 191 | #html_use_opensearch = '' 192 | 193 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 194 | #html_file_suffix = None 195 | 196 | # Language to be used for generating the HTML full-text search index. 197 | # Sphinx supports the following languages: 198 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 199 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' 200 | #html_search_language = 'en' 201 | 202 | # A dictionary with options for the search language support, empty by default. 203 | # Now only 'ja' uses this config value 204 | #html_search_options = {'type': 'default'} 205 | 206 | # The name of a javascript file (relative to the configuration directory) that 207 | # implements a search results scorer. If empty, the default will be used. 208 | #html_search_scorer = 'scorer.js' 209 | 210 | # Output file base name for HTML help builder. 211 | htmlhelp_basename = 'SQLAlchemy-JSON-APIdoc' 212 | 213 | # -- Options for LaTeX output --------------------------------------------- 214 | 215 | latex_elements = { 216 | # The paper size ('letterpaper' or 'a4paper'). 217 | #'papersize': 'letterpaper', 218 | 219 | # The font size ('10pt', '11pt' or '12pt'). 220 | #'pointsize': '10pt', 221 | 222 | # Additional stuff for the LaTeX preamble. 223 | #'preamble': '', 224 | 225 | # Latex figure (float) alignment 226 | #'figure_align': 'htbp', 227 | } 228 | 229 | # Grouping the document tree into LaTeX files. List of tuples 230 | # (source start file, target name, title, 231 | # author, documentclass [howto, manual, or own class]). 232 | latex_documents = [ 233 | (master_doc, 'SQLAlchemy-JSON-API.tex', 'SQLAlchemy-JSON-API Documentation', 234 | 'Konsta Vesterinen', 'manual'), 235 | ] 236 | 237 | # The name of an image file (relative to this directory) to place at the top of 238 | # the title page. 239 | #latex_logo = None 240 | 241 | # For "manual" documents, if this is true, then toplevel headings are parts, 242 | # not chapters. 243 | #latex_use_parts = False 244 | 245 | # If true, show page references after internal links. 246 | #latex_show_pagerefs = False 247 | 248 | # If true, show URL addresses after external links. 249 | #latex_show_urls = False 250 | 251 | # Documents to append as an appendix to all manuals. 252 | #latex_appendices = [] 253 | 254 | # If false, no module index is generated. 255 | #latex_domain_indices = True 256 | 257 | 258 | # -- Options for manual page output --------------------------------------- 259 | 260 | # One entry per manual page. List of tuples 261 | # (source start file, name, description, authors, manual section). 262 | man_pages = [ 263 | (master_doc, 'sqlalchemy-json-api', 'SQLAlchemy-JSON-API Documentation', 264 | [author], 1) 265 | ] 266 | 267 | # If true, show URL addresses after external links. 268 | #man_show_urls = False 269 | 270 | 271 | # -- Options for Texinfo output ------------------------------------------- 272 | 273 | # Grouping the document tree into Texinfo files. List of tuples 274 | # (source start file, target name, title, author, 275 | # dir menu entry, description, category) 276 | texinfo_documents = [ 277 | (master_doc, 'SQLAlchemy-JSON-API', 'SQLAlchemy-JSON-API Documentation', 278 | author, 'SQLAlchemy-JSON-API', 'One line description of project.', 279 | 'Miscellaneous'), 280 | ] 281 | 282 | # Documents to append as an appendix to all manuals. 283 | #texinfo_appendices = [] 284 | 285 | # If false, no module index is generated. 286 | #texinfo_domain_indices = True 287 | 288 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 289 | #texinfo_show_urls = 'footnote' 290 | 291 | # If true, do not generate a @detailmenu in the "Top" node's menu. 292 | #texinfo_no_detailmenu = False 293 | 294 | 295 | # Example configuration for intersphinx: refer to the Python standard library. 296 | intersphinx_mapping = {'https://docs.python.org/': None} 297 | -------------------------------------------------------------------------------- /docs/fields.rst: -------------------------------------------------------------------------------- 1 | Selecting fields 2 | ---------------- 3 | 4 | By default SQLAlchemy-JSON-API selects all orm descriptors (except synonyms) for given model. This includes: 5 | 6 | * Column properties 7 | * Hybrid properties 8 | * Relationship properties 9 | 10 | Please notice that you can't include regular descriptors, only orm descriptors. 11 | 12 | The id property 13 | ^^^^^^^^^^^^^^^ 14 | 15 | Each included model MUST have an ``id`` property. Usually this should be the primary key of your model. If your model doesn't have an ``id`` property you can add one by using for example SQLAlchemy hybrids. 16 | 17 | 18 | :: 19 | 20 | from sqlalchemy.ext.hybrid import hybrid_property 21 | 22 | 23 | class GroupInvitation(Base): 24 | group_id = sa.Column( 25 | sa.Integer, 26 | sa.ForeignKey(Group.id), 27 | primary_key=True 28 | ) 29 | user_id = sa.Column( 30 | sa.Integer, 31 | sa.ForeignKey(User.id), 32 | primary_key=True 33 | ) 34 | issued_at = sa.Column(sa.DateTime) 35 | 36 | @hybrid_property 37 | def id(self): 38 | return self.group_id + ':' + self.user_id 39 | 40 | .. note:: 41 | 42 | SQLAlchemy-JSON-API always returns the id as a string. If the type of the id property is not a string 43 | SQLAlchemy-JSON-API tries to cast the given property to string. 44 | 45 | 46 | Customizing field selection 47 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 48 | 49 | You can customize this behaviour by providing the ``fields`` parameter to :meth:`.QueryBuilder.select`. 50 | 51 | :: 52 | 53 | 54 | query_builder.select(Article, fields={'articles': ['name']}) 55 | result = session.execute(query).scalar() 56 | # { 57 | # 'data': [{ 58 | # 'id': '1', 59 | # 'type': 'articles', 60 | # 'attributes': { 61 | # 'name': 'Some article', 62 | # }, 63 | # }] 64 | # } 65 | 66 | 67 | If you only want to select id for given model you need to provide empty list for given model key. 68 | 69 | 70 | :: 71 | 72 | 73 | query = query_builder.select(Article, fields={'articles': []}) 74 | result = session.execute(query).scalar() 75 | # { 76 | # 'data': [{ 77 | # 'id': '1', 78 | # 'type': 'articles', 79 | # }] 80 | # } 81 | 82 | 83 | -------------------------------------------------------------------------------- /docs/filtering.rst: -------------------------------------------------------------------------------- 1 | Filtering queries 2 | ----------------- 3 | 4 | 5 | You can filter query results by providing the ``from_obj`` parameter for :meth:`.QueryBuilder.select`. 6 | This parameter can be any SQLAlchemy selectable construct. 7 | 8 | 9 | :: 10 | 11 | 12 | base_query = session.query(Article).filter(Article.name == 'Some article') 13 | 14 | query = query_builder.select( 15 | Article, 16 | fields={'articles': ['name']}, 17 | from_obj=base_query 18 | ) 19 | result = session.execute(query).scalar() 20 | # { 21 | # 'data': [{ 22 | # 'id': '1', 23 | # 'type': 'articles', 24 | # 'attributes': { 25 | # 'name': 'Some article', 26 | # }, 27 | # }] 28 | # } 29 | 30 | 31 | You can also limit the results by giving ``limit`` and ``offset`` parameters. 32 | 33 | :: 34 | 35 | 36 | query = query_builder.select( 37 | Article, 38 | fields={'articles': ['name']}, 39 | limit=5, 40 | offset=10 41 | ) 42 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | SQLAlchemy-JSON-API 2 | =================== 3 | 4 | Contents: 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | installation 10 | quickstart 11 | fields 12 | compound_documents 13 | sorting 14 | filtering 15 | type_formatting 16 | api 17 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ------------ 3 | 4 | This part of the documentation covers the installation of SQLAlchemy-JSON-API. 5 | 6 | Supported platforms 7 | ~~~~~~~~~~~~~~~~~~~ 8 | 9 | SQLAlchemy-JSON-API has been tested against the following Python platforms. 10 | 11 | - cPython 2.7 12 | - cPython 3.3 13 | - cPython 3.4 14 | - cPython 3.5 15 | 16 | 17 | Installing an official release 18 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 19 | 20 | You can install the most recent official SQLAlchemy-JSON-API version using 21 | pip_:: 22 | 23 | pip install sqlalchemy-json-api 24 | 25 | .. _pip: http://www.pip-installer.org/ 26 | 27 | Installing the development version 28 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 29 | 30 | To install the latest version of SQLAlchemy-JSON-API, you need first obtain a 31 | copy of the source. You can do that by cloning the git_ repository:: 32 | 33 | git clone git://github.com/kvesteri/sqlalchemy-json-api.git 34 | 35 | Then you can install the source distribution using the ``setup.py`` 36 | script:: 37 | 38 | cd sqlalchemy-json-api 39 | python setup.py install 40 | 41 | .. _git: http://git-scm.org/ 42 | 43 | Checking the installation 44 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 45 | 46 | To check that SQLAlchemy-JSON-API has been properly installed, type ``python`` 47 | from your shell. Then at the Python prompt, try to import SQLAlchemy-JSON-API, 48 | and check the installed version: 49 | 50 | .. parsed-literal:: 51 | 52 | >>> import sqlalchemy_json_api 53 | >>> sqlalchemy_json_api.__version__ 54 | |release| 55 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | echo. coverage to run coverage check of the documentation if enabled 41 | goto end 42 | ) 43 | 44 | if "%1" == "clean" ( 45 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 46 | del /q /s %BUILDDIR%\* 47 | goto end 48 | ) 49 | 50 | 51 | REM Check if sphinx-build is available and fallback to Python version if any 52 | %SPHINXBUILD% 2> nul 53 | if errorlevel 9009 goto sphinx_python 54 | goto sphinx_ok 55 | 56 | :sphinx_python 57 | 58 | set SPHINXBUILD=python -m sphinx.__init__ 59 | %SPHINXBUILD% 2> nul 60 | if errorlevel 9009 ( 61 | echo. 62 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 63 | echo.installed, then set the SPHINXBUILD environment variable to point 64 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 65 | echo.may add the Sphinx directory to PATH. 66 | echo. 67 | echo.If you don't have Sphinx installed, grab it from 68 | echo.http://sphinx-doc.org/ 69 | exit /b 1 70 | ) 71 | 72 | :sphinx_ok 73 | 74 | 75 | if "%1" == "html" ( 76 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 80 | goto end 81 | ) 82 | 83 | if "%1" == "dirhtml" ( 84 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 88 | goto end 89 | ) 90 | 91 | if "%1" == "singlehtml" ( 92 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 93 | if errorlevel 1 exit /b 1 94 | echo. 95 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 96 | goto end 97 | ) 98 | 99 | if "%1" == "pickle" ( 100 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 101 | if errorlevel 1 exit /b 1 102 | echo. 103 | echo.Build finished; now you can process the pickle files. 104 | goto end 105 | ) 106 | 107 | if "%1" == "json" ( 108 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 109 | if errorlevel 1 exit /b 1 110 | echo. 111 | echo.Build finished; now you can process the JSON files. 112 | goto end 113 | ) 114 | 115 | if "%1" == "htmlhelp" ( 116 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 117 | if errorlevel 1 exit /b 1 118 | echo. 119 | echo.Build finished; now you can run HTML Help Workshop with the ^ 120 | .hhp project file in %BUILDDIR%/htmlhelp. 121 | goto end 122 | ) 123 | 124 | if "%1" == "qthelp" ( 125 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 129 | .qhcp project file in %BUILDDIR%/qthelp, like this: 130 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\SQLAlchemy-JSON-API.qhcp 131 | echo.To view the help file: 132 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\SQLAlchemy-JSON-API.ghc 133 | goto end 134 | ) 135 | 136 | if "%1" == "devhelp" ( 137 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. 141 | goto end 142 | ) 143 | 144 | if "%1" == "epub" ( 145 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 149 | goto end 150 | ) 151 | 152 | if "%1" == "latex" ( 153 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 157 | goto end 158 | ) 159 | 160 | if "%1" == "latexpdf" ( 161 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 162 | cd %BUILDDIR%/latex 163 | make all-pdf 164 | cd %~dp0 165 | echo. 166 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdfja" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf-ja 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "text" ( 181 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 182 | if errorlevel 1 exit /b 1 183 | echo. 184 | echo.Build finished. The text files are in %BUILDDIR%/text. 185 | goto end 186 | ) 187 | 188 | if "%1" == "man" ( 189 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 190 | if errorlevel 1 exit /b 1 191 | echo. 192 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 193 | goto end 194 | ) 195 | 196 | if "%1" == "texinfo" ( 197 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 198 | if errorlevel 1 exit /b 1 199 | echo. 200 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 201 | goto end 202 | ) 203 | 204 | if "%1" == "gettext" ( 205 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 206 | if errorlevel 1 exit /b 1 207 | echo. 208 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 209 | goto end 210 | ) 211 | 212 | if "%1" == "changes" ( 213 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 214 | if errorlevel 1 exit /b 1 215 | echo. 216 | echo.The overview file is in %BUILDDIR%/changes. 217 | goto end 218 | ) 219 | 220 | if "%1" == "linkcheck" ( 221 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 222 | if errorlevel 1 exit /b 1 223 | echo. 224 | echo.Link check complete; look for any errors in the above output ^ 225 | or in %BUILDDIR%/linkcheck/output.txt. 226 | goto end 227 | ) 228 | 229 | if "%1" == "doctest" ( 230 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 231 | if errorlevel 1 exit /b 1 232 | echo. 233 | echo.Testing of doctests in the sources finished, look at the ^ 234 | results in %BUILDDIR%/doctest/output.txt. 235 | goto end 236 | ) 237 | 238 | if "%1" == "coverage" ( 239 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 240 | if errorlevel 1 exit /b 1 241 | echo. 242 | echo.Testing of coverage in the sources finished, look at the ^ 243 | results in %BUILDDIR%/coverage/python.txt. 244 | goto end 245 | ) 246 | 247 | if "%1" == "xml" ( 248 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 249 | if errorlevel 1 exit /b 1 250 | echo. 251 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 252 | goto end 253 | ) 254 | 255 | if "%1" == "pseudoxml" ( 256 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 257 | if errorlevel 1 exit /b 1 258 | echo. 259 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 260 | goto end 261 | ) 262 | 263 | :end 264 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ---------- 3 | 4 | Consider the following model definition. 5 | 6 | :: 7 | 8 | import sqlalchemy as sa 9 | from sqlalchemy.ext.declarative import declarative_base 10 | 11 | 12 | Base = declarative_base() 13 | 14 | 15 | class Article(Base): 16 | __tablename__ = 'article' 17 | id = sa.Column('_id', sa.Integer, primary_key=True) 18 | name = sa.Column(sa.String) 19 | content = sa.Column(sa.String) 20 | 21 | 22 | class Comment(Base): 23 | __tablename__ = 'comment' 24 | id = sa.Column(sa.Integer, primary_key=True) 25 | content = sa.Column(sa.String) 26 | article_id = sa.Column(sa.Integer, sa.ForeignKey(Article.id)) 27 | article = sa.orm.relationship(article_cls, backref='comments') 28 | 29 | 30 | In order to use SQLAlchemy-JSON-API you need to first initialize a :class:`.QueryBuilder` by providing it 31 | a class mapping. 32 | 33 | :: 34 | 35 | 36 | from sqlalchemy_json_api import QueryBuilder 37 | 38 | 39 | query_builder = QueryBuilder({ 40 | 'articles': Article, 41 | 'comments': Comment 42 | }) 43 | 44 | 45 | Now we can start using it by selecting from the existing resources. 46 | 47 | :: 48 | 49 | query = query_builder.select(Article) 50 | result = session.execute(query).scalar() 51 | # { 52 | # 'data': [{ 53 | # 'id': '1', 54 | # 'type': 'articles', 55 | # 'attributes': { 56 | # 'content': 'Some content', 57 | # 'name': 'Some article', 58 | # }, 59 | # 'relationships': { 60 | # 'comments': { 61 | # 'data': [ 62 | # {'id': '1', 'type': 'comments'}, 63 | # {'id': '2', 'type': 'comments'} 64 | # ] 65 | # }, 66 | # }, 67 | # }] 68 | # } 69 | 70 | You can also make the query builder build queries that return the results as 71 | raw json by using the ``as_text`` parameter. 72 | 73 | :: 74 | 75 | query = query_builder.select(Article, as_text=True) 76 | result = session.execute(query).scalar() 77 | # '{ 78 | # "data": [{ 79 | # "id": "1", 80 | # "type": "articles", 81 | # "attributes": { 82 | # "content": "Some content", 83 | # "name": "Some article", 84 | # }, 85 | # "relationships": { 86 | # "comments": { 87 | # "data": [ 88 | # {"id": "1", "type": "comments"}, 89 | # {"id": "2", "type": "comments"} 90 | # ] 91 | # }, 92 | # }, 93 | # }] 94 | # }' 95 | -------------------------------------------------------------------------------- /docs/sorting.rst: -------------------------------------------------------------------------------- 1 | Sorting queries 2 | --------------- 3 | 4 | You can apply an order by to builded query by providing it a ``sort`` parameter. 5 | This parameter should be a list of root resource attribute names. 6 | 7 | Sort by name ascending 8 | 9 | :: 10 | 11 | 12 | query = query_builder.select( 13 | Article, 14 | sort=['name'] 15 | ) 16 | 17 | 18 | Sort by name descending first and id ascending second 19 | 20 | 21 | :: 22 | 23 | query = query_builder.select( 24 | Article, 25 | sort=['-name', 'id'] 26 | ) 27 | 28 | 29 | .. note:: 30 | 31 | SQLAlchemy-JSON-API does NOT support sorting by related resource attribute 32 | at the moment. 33 | -------------------------------------------------------------------------------- /docs/type_formatting.rst: -------------------------------------------------------------------------------- 1 | Type based formatting 2 | --------------------- 3 | 4 | 5 | Sometimes you may want type based formatting, eg. forcing all datetimes in ISO standard format. 6 | You can easily achieve this by using ``type_formatters`` parameter for :meth:`.QueryBuilder`. 7 | 8 | 9 | :: 10 | 11 | 12 | def isoformat(date): 13 | return sa.func.to_char( 14 | date, 15 | sa.text('\'YYYY-MM-DD"T"HH24:MI:SS.US"Z"\'') 16 | ).label(date.name) 17 | 18 | query_builder.type_formatters = { 19 | sa.DateTime: isoformat 20 | } 21 | 22 | query = query_builder.select( 23 | Article, 24 | fields={'articles': ['name', 'created_at']}, 25 | from_obj=base_query 26 | ) 27 | result = session.execute(query).scalar() 28 | # { 29 | # 'data': [{ 30 | # 'id': '1', 31 | # 'type': 'articles', 32 | # 'attributes': { 33 | # 'name': 'Some article', 34 | # 'created_at': '2011-01-01T00:00:00.000000Z' 35 | # }, 36 | # }] 37 | # } 38 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | SQLAlchemy-JSON-API 3 | ------------------- 4 | Fast SQLAlchemy query builder for returning JSON API responses 5 | """ 6 | from setuptools import setup, find_packages 7 | import os 8 | import re 9 | import sys 10 | 11 | 12 | HERE = os.path.dirname(os.path.abspath(__file__)) 13 | PY3 = sys.version_info[0] == 3 14 | 15 | 16 | def get_version(): 17 | filename = os.path.join(HERE, 'sqlalchemy_json_api', '__init__.py') 18 | with open(filename) as f: 19 | contents = f.read() 20 | pattern = r"^__version__ = '(.*?)'$" 21 | return re.search(pattern, contents, re.MULTILINE).group(1) 22 | 23 | 24 | extras_require = { 25 | 'test': [ 26 | 'pytest>=3.0.7', 27 | 'Pygments>=1.2', 28 | 'six>=1.4.1', 29 | 'psycopg2>=2.6.1', 30 | 'flake8>=2.4.0', 31 | 'isort==4.2.5', 32 | 'natsort==3.5.6', 33 | ], 34 | } 35 | 36 | 37 | setup( 38 | name='SQLAlchemy-JSON-API', 39 | version=get_version(), 40 | url='https://github.com/kvesteri/sqlalchemy-json-api', 41 | license='BSD', 42 | author='Konsta Vesterinen', 43 | author_email='konsta@fastmonkeys.com', 44 | description=( 45 | 'Fast SQLAlchemy query builder for returning JSON API responses.' 46 | ), 47 | long_description=__doc__, 48 | packages=find_packages('.'), 49 | zip_safe=False, 50 | include_package_data=True, 51 | platforms='any', 52 | dependency_links=[], 53 | install_requires=[ 54 | 'SQLAlchemy-Utils>=0.32.19' 55 | ], 56 | extras_require=extras_require, 57 | classifiers=[ 58 | 'Environment :: Web Environment', 59 | 'Intended Audience :: Developers', 60 | 'License :: OSI Approved :: BSD License', 61 | 'Operating System :: OS Independent', 62 | 'Programming Language :: Python', 63 | 'Programming Language :: Python', 64 | 'Programming Language :: Python :: 2', 65 | 'Programming Language :: Python :: 2.7', 66 | 'Programming Language :: Python :: 3', 67 | 'Programming Language :: Python :: 3.4', 68 | 'Programming Language :: Python :: 3.5', 69 | 'Programming Language :: Python :: 3.6', 70 | 'Programming Language :: Python :: Implementation :: CPython', 71 | 'Programming Language :: Python :: Implementation :: PyPy', 72 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 73 | 'Topic :: Software Development :: Libraries :: Python Modules' 74 | ] 75 | ) 76 | -------------------------------------------------------------------------------- /sqlalchemy_json_api/__init__.py: -------------------------------------------------------------------------------- 1 | from .exc import ( # noqa 2 | IdPropertyNotFound, 3 | InvalidField, 4 | UnknownField, 5 | UnknownFieldKey, 6 | UnknownModel 7 | ) 8 | from .hybrids import CompositeId # noqa 9 | from .query_builder import QueryBuilder, RESERVED_KEYWORDS # noqa 10 | from .utils import assert_json_document # noqa 11 | 12 | __version__ = '0.4.7' 13 | -------------------------------------------------------------------------------- /sqlalchemy_json_api/exc.py: -------------------------------------------------------------------------------- 1 | class QueryBuilderException(Exception): 2 | pass 3 | 4 | 5 | class InvalidField(QueryBuilderException): 6 | """ 7 | This error is raised when trying to include a foreign key field or if the 8 | field is reserved keyword. 9 | """ 10 | pass 11 | 12 | 13 | class UnknownField(QueryBuilderException): 14 | """ 15 | This error is raised if the selectable given to one of the select_* methods 16 | of QueryBuilder does not contain given field. 17 | """ 18 | pass 19 | 20 | 21 | class UnknownModel(QueryBuilderException): 22 | """ 23 | If the resource registry of this query builder does not contain the 24 | given model. 25 | """ 26 | pass 27 | 28 | 29 | class UnknownFieldKey(QueryBuilderException): 30 | """ 31 | If the given field list key is not present in the resource registry of 32 | a query builder. 33 | """ 34 | pass 35 | 36 | 37 | class IdPropertyNotFound(QueryBuilderException): 38 | """ 39 | This error is raised when one of the referenced models in QueryBuilder 40 | query building process does not have an id property. 41 | """ 42 | pass 43 | -------------------------------------------------------------------------------- /sqlalchemy_json_api/hybrids.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sa 2 | from sqlalchemy.ext.hybrid import Comparator 3 | 4 | 5 | class CompositeId(Comparator): 6 | def __init__(self, keys, separator=':', label='id'): 7 | self.keys = keys 8 | self.separator = separator 9 | self.label = label 10 | 11 | def operate(self, op, other): 12 | if isinstance(other, sa.sql.selectable.Select): 13 | return op(sa.tuple_(*self.keys), other) 14 | if not isinstance(other, CompositeId): 15 | other = CompositeId(other) 16 | return sa.and_( 17 | op(key, other_key) 18 | for key, other_key in zip(self.keys, other.keys) 19 | ) 20 | 21 | def __clause_element__(self): 22 | parts = [self.keys[0]] 23 | for key in self.keys[1:]: 24 | parts.append(sa.text("'{}'".format(self.separator))) 25 | parts.append(key) 26 | return sa.func.concat(*parts).label(self.label) 27 | 28 | def __str__(self): 29 | return self.separator.join(str(k) for k in self.keys) 30 | 31 | def __repr__(self): 32 | return ''.format(repr(self.keys)) 33 | -------------------------------------------------------------------------------- /sqlalchemy_json_api/query_builder.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from itertools import chain 3 | 4 | import sqlalchemy as sa 5 | from sqlalchemy.dialects import postgresql 6 | from sqlalchemy.dialects.postgresql import JSON, JSONB 7 | from sqlalchemy.orm.attributes import InstrumentedAttribute 8 | from sqlalchemy.sql.elements import Label 9 | from sqlalchemy.sql.expression import union 10 | from sqlalchemy_utils import get_hybrid_properties 11 | from sqlalchemy_utils.functions import cast_if, get_mapper 12 | from sqlalchemy_utils.functions.orm import get_all_descriptors 13 | from sqlalchemy_utils.relationships import ( 14 | path_to_relationships, 15 | select_correlated_expression 16 | ) 17 | 18 | from .exc import ( 19 | IdPropertyNotFound, 20 | InvalidField, 21 | UnknownField, 22 | UnknownFieldKey, 23 | UnknownModel 24 | ) 25 | from .hybrids import CompositeId 26 | from .utils import ( 27 | adapt, 28 | chain_if, 29 | get_attrs, 30 | get_descriptor_columns, 31 | get_selectable, 32 | s, 33 | subpaths 34 | ) 35 | 36 | Parameters = namedtuple( 37 | 'Parameters', 38 | ['fields', 'include', 'sort', 'offset', 'limit'] 39 | ) 40 | 41 | json_array = sa.cast( 42 | postgresql.array([], type_=JSON), postgresql.ARRAY(JSON) 43 | ) 44 | jsonb_array = sa.cast( 45 | postgresql.array([], type_=JSONB), postgresql.ARRAY(JSONB) 46 | ) 47 | 48 | RESERVED_KEYWORDS = ( 49 | 'id', 50 | 'type', 51 | ) 52 | 53 | 54 | class ResourceRegistry(object): 55 | def __init__(self, model_mapping): 56 | self.by_type = model_mapping 57 | self.by_model_class = dict( 58 | (value, key) for key, value in model_mapping.items() 59 | ) 60 | 61 | 62 | class QueryBuilder(object): 63 | """ 64 | 1. Simple example 65 | :: 66 | 67 | query_builder = QueryBuilder({ 68 | 'articles': Article, 69 | 'users': User, 70 | 'comments': Comment 71 | }) 72 | 73 | 2. Example using type formatters:: 74 | 75 | 76 | def isoformat(date): 77 | return sa.func.to_char( 78 | date, 79 | sa.text('\'YYYY-MM-DD"T"HH24:MI:SS.US"Z"\'') 80 | ).label(date.name) 81 | 82 | query_builder = QueryBuilder( 83 | { 84 | 'articles': Article, 85 | 'users': User, 86 | 'comments': Comment 87 | }, 88 | type_formatters={sa.DateTime: isoformat} 89 | ) 90 | 91 | 92 | :param model_mapping: 93 | A mapping with keys representing JSON API resource identifier type 94 | names and values as SQLAlchemy models. 95 | 96 | It is recommended to use lowercased pluralized and hyphenized names for 97 | resource identifier types. So for example model such as 98 | LeagueInvitiation should have an equivalent key of 99 | 'league-invitations'. 100 | :param base_url: 101 | Base url to be used for building JSON API compatible links objects. By 102 | default this is `None` indicating that no link objects will be built. 103 | :param type_formatters: 104 | A dictionary of type formatters 105 | :param sort_included: 106 | Whether or not to sort included objects by type and id. 107 | """ 108 | def __init__( 109 | self, 110 | model_mapping, 111 | base_url=None, 112 | type_formatters=None, 113 | sort_included=True 114 | ): 115 | self.validate_model_mapping(model_mapping) 116 | self.resource_registry = ResourceRegistry(model_mapping) 117 | self.base_url = base_url 118 | self.type_formatters = ( 119 | {} if type_formatters is None else type_formatters 120 | ) 121 | self.sort_included = sort_included 122 | 123 | def validate_model_mapping(self, model_mapping): 124 | for model in model_mapping.values(): 125 | if 'id' not in get_all_descriptors(model).keys(): 126 | raise IdPropertyNotFound( 127 | "Couldn't find 'id' property for model {0}.".format( 128 | model 129 | ) 130 | ) 131 | 132 | def get_resource_type(self, model): 133 | if isinstance(model, sa.orm.util.AliasedClass): 134 | model = sa.inspect(model).mapper.class_ 135 | try: 136 | return self.resource_registry.by_model_class[model] 137 | except KeyError: 138 | raise UnknownModel( 139 | 'Unknown model given. Could not find model %r from given ' 140 | 'model mapping.' % model 141 | ) 142 | 143 | def get_id(self, from_obj): 144 | return cast_if(get_attrs(from_obj).id, sa.String) 145 | 146 | def build_resource_identifier(self, model, from_obj): 147 | model_alias = self.get_resource_type(model) 148 | return [ 149 | s('id'), 150 | cast_if( 151 | AttributesExpression( 152 | self, 153 | model, 154 | from_obj 155 | ).adapt_attribute('id'), 156 | sa.String 157 | ), 158 | s('type'), 159 | s(model_alias), 160 | ] 161 | 162 | def select_related(self, obj, relationship_key, **kwargs): 163 | """ 164 | Builds a query for selecting related resource(s). This method can be 165 | used for building select queries for JSON requests such as:: 166 | 167 | GET articles/1/author 168 | 169 | Usage:: 170 | 171 | article = session.query(Article).get(1) 172 | 173 | query = query_builder.select_related( 174 | article, 175 | 'category' 176 | ) 177 | 178 | :param obj: 179 | The root object to select the related resources from. 180 | :param fields: 181 | A mapping of fields. Keys representing model keys and values as 182 | lists of model descriptor names. 183 | :param include: 184 | List of dot-separated relationship paths. 185 | :param links: 186 | A dictionary of links to apply as top level links in the built 187 | query. Keys representing json keys and values as valid urls or 188 | dictionaries. 189 | :param sort: 190 | List of attributes to apply as an order by for the root model. 191 | :param from_obj: 192 | A SQLAlchemy selectable (for example a Query object) to select the 193 | query results from. 194 | :param as_text: 195 | Whether or not to build a query that returns the results as text 196 | (raw json). 197 | 198 | .. versionadded: 0.2 199 | """ 200 | return self._select_related(obj, relationship_key, **kwargs) 201 | 202 | def select_relationship(self, obj, relationship_key, **kwargs): 203 | """ 204 | Builds a query for selecting relationship resource(s):: 205 | 206 | article = session.query(Article).get(1) 207 | 208 | query = query_builder.select_related( 209 | article, 210 | 'category' 211 | ) 212 | 213 | 214 | :param obj: 215 | The root object to select the related resources from. 216 | :param sort: 217 | List of attributes to apply as an order by for the root model. 218 | :param links: 219 | A dictionary of links to apply as top level links in the built 220 | query. Keys representing json keys and values as valid urls or 221 | dictionaries. 222 | :param from_obj: 223 | A SQLAlchemy selectable (for example a Query object) to select the 224 | query results from. 225 | :param as_text: 226 | Whether or not to build a query that returns the results as text 227 | (raw json). 228 | 229 | .. versionadded: 0.2 230 | """ 231 | kwargs['ids_only'] = True 232 | return self._select_related(obj, relationship_key, **kwargs) 233 | 234 | def _select_related(self, obj, relationship_key, **kwargs): 235 | mapper = sa.inspect(obj.__class__) 236 | prop = mapper.relationships[relationship_key] 237 | model = prop.mapper.class_ 238 | 239 | from_obj = kwargs.pop('from_obj', None) 240 | if from_obj is None: 241 | from_obj = sa.orm.query.Query(model) 242 | 243 | # SQLAlchemy Query.with_parent throws warning if the primary object 244 | # foreign key is NULL. Thus we need this ugly magic to return empty 245 | # data in that scenario. 246 | if ( 247 | prop.direction.name == 'MANYTOONE' and 248 | not prop.secondary and 249 | getattr(obj, prop.local_remote_pairs[0][0].key) is None 250 | ): 251 | expr = sa.cast({'data': None}, JSONB) 252 | if kwargs.get('as_text'): 253 | expr = sa.cast(expr, sa.Text) 254 | return sa.select([expr]) 255 | 256 | from_obj = from_obj.with_parent(obj, prop) 257 | 258 | if prop.order_by: 259 | from_obj = from_obj.order_by(*prop.order_by) 260 | 261 | from_obj = from_obj.subquery() 262 | 263 | return SelectExpression(self, model, from_obj).build_select( 264 | multiple=prop.uselist, 265 | **kwargs 266 | ) 267 | 268 | def select(self, model, **kwargs): 269 | """ 270 | Builds a query for selecting multiple resource instances:: 271 | 272 | query = query_builder.select( 273 | Article, 274 | fields={'articles': ['name', 'author', 'comments']}, 275 | include=['author', 'comments.author'], 276 | from_obj=session.query(Article).filter( 277 | Article.id.in_([1, 2, 3, 4]) 278 | ) 279 | ) 280 | 281 | Results can be sorted:: 282 | 283 | # Sort by id in descending order 284 | query = query_builder.select( 285 | Article, 286 | sort=['-id'] 287 | ) 288 | 289 | # Sort by name and id in ascending order 290 | query = query_builder.select( 291 | Article, 292 | sort=['name', 'id'] 293 | ) 294 | 295 | :param model: 296 | The root model to build the select query from. 297 | :param fields: 298 | A mapping of fields. Keys representing model keys and values as 299 | lists of model descriptor names. 300 | :param include: 301 | List of dot-separated relationship paths. 302 | :param sort: 303 | List of attributes to apply as an order by for the root model. 304 | :param limit: 305 | Applies an SQL LIMIT to the generated query. 306 | :param offset: 307 | Applies an SQL OFFSET to the generated query. 308 | :param links: 309 | A dictionary of links to apply as top level links in the built 310 | query. Keys representing json keys and values as valid urls or 311 | dictionaries. 312 | :param from_obj: 313 | A SQLAlchemy selectable (for example a Query object) to select the 314 | query results from. 315 | :param as_text: 316 | Whether or not to build a query that returns the results as text 317 | (raw json). 318 | """ 319 | from_obj = kwargs.pop('from_obj', None) 320 | if from_obj is None: 321 | from_obj = sa.orm.query.Query(model) 322 | 323 | if kwargs.get('sort') is not None: 324 | from_obj = apply_sort( 325 | from_obj.statement, 326 | from_obj, 327 | kwargs.get('sort') 328 | ) 329 | if kwargs.get('limit') is not None: 330 | from_obj = from_obj.limit(kwargs.get('limit')) 331 | if kwargs.get('offset') is not None: 332 | from_obj = from_obj.offset(kwargs.get('offset')) 333 | 334 | from_obj = from_obj.cte('main_query') 335 | 336 | return SelectExpression(self, model, from_obj).build_select(**kwargs) 337 | 338 | def select_one(self, model, id, **kwargs): 339 | """ 340 | Builds a query for selecting single resource instance. 341 | 342 | :: 343 | 344 | query = query_builder.select_one( 345 | Article, 346 | 1, 347 | fields={'articles': ['name', 'author', 'comments']}, 348 | include=['author', 'comments.author'], 349 | ) 350 | 351 | 352 | :param model: 353 | The root model to build the select query from. 354 | :param id: 355 | The id of the resource to select. 356 | :param fields: 357 | A mapping of fields. Keys representing model keys and values as 358 | lists of model descriptor names. 359 | :param include: 360 | List of dot-separated relationship paths. 361 | :param links: 362 | A dictionary of links to apply as top level links in the built 363 | query. Keys representing json keys and values as valid urls or 364 | dictionaries. 365 | :param from_obj: 366 | A SQLAlchemy selectable (for example a Query object) to select the 367 | query results from. 368 | :param as_text: 369 | Whether or not to build a query that returns the results as text 370 | (raw json). 371 | """ 372 | from_obj = kwargs.pop('from_obj', None) 373 | if from_obj is None: 374 | from_obj = sa.orm.query.Query(model) 375 | 376 | from_obj = from_obj.filter(model.id == id).subquery() 377 | 378 | query = SelectExpression(self, model, from_obj).build_select( 379 | multiple=False, 380 | **kwargs 381 | ) 382 | query = query.where(query._froms[0].c.data.isnot(None)) 383 | return query 384 | 385 | 386 | class Expression(object): 387 | def __init__(self, query_builder, model, from_obj): 388 | self.query_builder = query_builder 389 | self.model = model 390 | self.from_obj = from_obj 391 | 392 | @property 393 | def args(self): 394 | return [self.query_builder, self.model, self.from_obj] 395 | 396 | 397 | class SelectExpression(Expression): 398 | def validate_field_keys(self, fields): 399 | if fields: 400 | unknown_keys = ( 401 | set(fields) - 402 | set(self.query_builder.resource_registry.by_type.keys()) 403 | ) 404 | if unknown_keys: 405 | raise UnknownFieldKey( 406 | 'Unknown field keys given. Could not find {0} {1} from ' 407 | 'given model mapping.'.format( 408 | 'keys' if len(unknown_keys) > 1 else 'key', 409 | ','.join("'{0}'".format(key) for key in unknown_keys) 410 | ) 411 | ) 412 | 413 | def build_select( 414 | self, 415 | fields=None, 416 | include=None, 417 | sort=None, 418 | limit=None, 419 | offset=None, 420 | links=None, 421 | multiple=True, 422 | ids_only=False, 423 | as_text=False 424 | ): 425 | self.validate_field_keys(fields) 426 | if fields is None: 427 | fields = {} 428 | 429 | params = Parameters( 430 | fields=fields, 431 | include=include, 432 | sort=sort, 433 | limit=limit, 434 | offset=offset 435 | ) 436 | from_args = self._get_from_args( 437 | params, 438 | multiple, 439 | ids_only, 440 | links 441 | ) 442 | 443 | main_json_query = sa.select(from_args).alias('main_json_query') 444 | 445 | expr = sa.func.row_to_json(sa.text('main_json_query.*')) 446 | if as_text: 447 | expr = sa.cast(expr, sa.Text) 448 | 449 | query = sa.select( 450 | [expr], 451 | from_obj=main_json_query 452 | ) 453 | return query 454 | 455 | def _get_from_args( 456 | self, 457 | params, 458 | multiple, 459 | ids_only, 460 | links 461 | ): 462 | data_expr = DataExpression(*self.args) 463 | data_query = ( 464 | data_expr.build_data_array(params, ids_only=ids_only) 465 | if multiple else 466 | data_expr.build_data(params, ids_only=ids_only) 467 | ) 468 | from_args = [data_query.as_scalar().label('data')] 469 | 470 | if params.include: 471 | selectable = self.from_obj 472 | include_expr = IncludeExpression( 473 | self.query_builder, 474 | self.model, 475 | selectable 476 | ) 477 | included_query = include_expr.build_included(params) 478 | from_args.append(included_query.as_scalar().label('included')) 479 | 480 | if links: 481 | from_args.append( 482 | sa.func.json_build_object( 483 | *chain(*links.items()) 484 | ).label('links') 485 | ) 486 | return from_args 487 | 488 | 489 | def apply_sort(from_obj, query, sort): 490 | for param in sort: 491 | query = query.order_by( 492 | sa.desc(getattr(from_obj.c, param[1:])) 493 | if param[0] == '-' else 494 | getattr(from_obj.c, param) 495 | ) 496 | return query 497 | 498 | 499 | class AttributesExpression(Expression): 500 | @property 501 | def all_fields(self): 502 | return [ 503 | field 504 | for field, descriptor 505 | in self.adapted_descriptors 506 | if ( 507 | field != '__mapper__' and 508 | field not in RESERVED_KEYWORDS and 509 | not self.is_relationship_descriptor(descriptor) and 510 | not self.should_skip_columnar_descriptor(descriptor) 511 | ) 512 | ] 513 | 514 | def should_skip_columnar_descriptor(self, descriptor): 515 | columns = get_descriptor_columns(self.from_obj, descriptor) 516 | return (len(columns) == 1 and columns[0].foreign_keys) 517 | 518 | @property 519 | def adapted_descriptors(self): 520 | return ( 521 | get_all_descriptors(self.from_obj).items() + 522 | [ 523 | ( 524 | key, 525 | adapt(self.from_obj, getattr(self.model, key)) 526 | ) 527 | for key in get_hybrid_properties(self.model).keys() 528 | ] 529 | ) 530 | 531 | def adapt_attribute(self, attr_name): 532 | cols = get_attrs(self.from_obj) 533 | hybrids = get_hybrid_properties(self.model).keys() 534 | if ( 535 | attr_name in hybrids or 536 | attr_name in self.column_property_expressions 537 | ): 538 | column = adapt(self.from_obj, getattr(self.model, attr_name)) 539 | else: 540 | column = getattr(cols, attr_name) 541 | return self.format_column(column) 542 | 543 | def format_column(self, column): 544 | for type_, formatter in self.query_builder.type_formatters.items(): 545 | if isinstance(column.type, type_): 546 | return formatter(column) 547 | return column 548 | 549 | def is_relationship_field(self, field): 550 | return field in get_mapper(self.model).relationships.keys() 551 | 552 | def is_relationship_descriptor(self, descriptor): 553 | return ( 554 | isinstance(descriptor, InstrumentedAttribute) and 555 | isinstance(descriptor.property, sa.orm.RelationshipProperty) 556 | ) 557 | 558 | def validate_column(self, field, column): 559 | # Check that given column is an actual Column object and not for 560 | # example select expression 561 | if isinstance(column, sa.Column): 562 | if column.foreign_keys: 563 | raise InvalidField( 564 | "Field '{0}' is invalid. The underlying column " 565 | "'{1}' has foreign key. You can't include foreign key " 566 | "attributes. Consider including relationship " 567 | "attributes.".format( 568 | field, column.key 569 | ) 570 | ) 571 | 572 | def validate_field(self, field, descriptors): 573 | if field in RESERVED_KEYWORDS: 574 | raise InvalidField( 575 | "Given field '{0}' is reserved keyword.".format(field) 576 | ) 577 | if field not in descriptors.keys(): 578 | raise UnknownField( 579 | "Unknown field '{0}'. Given selectable does not have " 580 | "descriptor named '{0}'.".format(field) 581 | ) 582 | columns = get_descriptor_columns(self.model, descriptors[field]) 583 | for column in columns: 584 | self.validate_column(field, column) 585 | 586 | def validate_fields(self, fields): 587 | descriptors = get_all_descriptors(self.from_obj) 588 | hybrids = get_hybrid_properties(self.model) 589 | expressions = self.column_property_expressions 590 | 591 | for field in fields: 592 | if field in hybrids or field in expressions: 593 | continue 594 | self.validate_field(field, descriptors) 595 | 596 | @property 597 | def column_property_expressions(self): 598 | return dict([ 599 | (key, attr) 600 | for key, attr 601 | in get_mapper(self.model).attrs.items() 602 | if ( 603 | isinstance(attr, sa.orm.ColumnProperty) and 604 | not isinstance(attr.columns[0], sa.Column) 605 | ) 606 | ]) 607 | 608 | def get_model_fields(self, fields): 609 | model_key = self.query_builder.get_resource_type(self.model) 610 | 611 | if not fields or model_key not in fields: 612 | model_fields = self.all_fields 613 | else: 614 | model_fields = [ 615 | field for field in fields[model_key] 616 | if not self.is_relationship_field(field) 617 | ] 618 | self.validate_fields(model_fields) 619 | return model_fields 620 | 621 | def build_attributes(self, fields): 622 | return chain_if( 623 | *( 624 | [s(key), self.adapt_attribute(key)] 625 | for key in self.get_model_fields(fields) 626 | ) 627 | ) 628 | 629 | 630 | class RelationshipsExpression(Expression): 631 | def build_relationships(self, fields): 632 | return chain_if( 633 | *( 634 | self.build_relationship(relationship) 635 | for relationship 636 | in self.get_relationship_properties(fields) 637 | ) 638 | ) 639 | 640 | def build_relationship_data(self, relationship, alias): 641 | identifier = self.query_builder.build_resource_identifier( 642 | alias, 643 | alias 644 | ) 645 | expr = sa.func.json_build_object(*identifier).label('json_object') 646 | query = select_correlated_expression( 647 | self.model, 648 | expr, 649 | relationship.key, 650 | alias, 651 | get_selectable(self.from_obj), 652 | order_by=self.build_order_by(relationship, alias) 653 | ).alias('relationships') 654 | return query 655 | 656 | def build_order_by(self, relationship, alias): 657 | if relationship.order_by is not False: 658 | return relationship.order_by 659 | 660 | if ( 661 | ( 662 | hasattr(alias.id, 'expression') and 663 | isinstance(alias.id.expression, Label) 664 | ) or 665 | isinstance(alias.id, Label) 666 | ): 667 | return alias.id.expression.get_children() 668 | return [alias.id] 669 | 670 | def build_relationship_data_array(self, relationship, alias): 671 | query = self.build_relationship_data(relationship, alias) 672 | return sa.select([ 673 | sa.func.coalesce( 674 | sa.func.array_agg(query.c.json_object), 675 | json_array 676 | ) 677 | ]).select_from(query) 678 | 679 | def build_relationship(self, relationship): 680 | cls = relationship.mapper.class_ 681 | alias = sa.orm.aliased(cls) 682 | query = ( 683 | self.build_relationship_data_array(relationship, alias) 684 | if relationship.uselist else 685 | self.build_relationship_data(relationship, alias) 686 | ) 687 | args = [s('data'), query.as_scalar()] 688 | if self.query_builder.base_url: 689 | links = LinksExpression(*self.args).build_relationship_links( 690 | relationship.key 691 | ) 692 | args.extend([ 693 | s('links'), 694 | sa.func.json_build_object(*links) 695 | ]) 696 | return [ 697 | s(relationship.key), 698 | sa.func.json_build_object(*args) 699 | ] 700 | 701 | def get_relationship_properties(self, fields): 702 | model_alias = self.query_builder.get_resource_type(self.model) 703 | mapper = get_mapper(self.model) 704 | if model_alias not in fields: 705 | return list(mapper.relationships.values()) 706 | else: 707 | return [ 708 | mapper.relationships[field] 709 | for field in fields[model_alias] 710 | if field in mapper.relationships.keys() 711 | ] 712 | 713 | 714 | class LinksExpression(Expression): 715 | def build_link(self, postfix=None): 716 | args = [ 717 | s(self.query_builder.base_url), 718 | s(self.query_builder.get_resource_type(self.model)), 719 | s('/'), 720 | self.query_builder.get_id(self.from_obj), 721 | ] 722 | if postfix is not None: 723 | args.append(postfix) 724 | return sa.func.concat(*args) 725 | 726 | def build_links(self): 727 | if self.query_builder.base_url: 728 | return [s('self'), self.build_link()] 729 | 730 | def build_relationship_links(self, key): 731 | if self.query_builder.base_url: 732 | return [ 733 | s('self'), 734 | self.build_link(s('/relationships/{0}'.format(key))), 735 | s('related'), 736 | self.build_link(s('/{0}'.format(key))) 737 | ] 738 | 739 | 740 | class DataExpression(Expression): 741 | def build_attrs_relationships_and_links(self, fields): 742 | args = (self.query_builder, self.model, self.from_obj) 743 | parts = { 744 | 'attributes': AttributesExpression(*args).build_attributes( 745 | fields 746 | ), 747 | 'relationships': RelationshipsExpression( 748 | *args 749 | ).build_relationships(fields), 750 | 'links': LinksExpression(*args).build_links() 751 | } 752 | return chain_if( 753 | *( 754 | [s(key), sa.func.json_build_object(*values)] 755 | for key, values in parts.items() 756 | if values 757 | ) 758 | ) 759 | 760 | def build_data_expr(self, params, ids_only=False): 761 | json_fields = self.query_builder.build_resource_identifier( 762 | self.model, 763 | self.from_obj 764 | ) 765 | if not ids_only: 766 | json_fields.extend( 767 | self.build_attrs_relationships_and_links(params.fields) 768 | ) 769 | return sa.func.json_build_object(*json_fields).label('data') 770 | 771 | def build_data(self, params, ids_only=False): 772 | expr = self.build_data_expr(params, ids_only=ids_only) 773 | query = sa.select([expr], from_obj=self.from_obj) 774 | return query 775 | 776 | def build_data_array(self, params, ids_only=False): 777 | data_query = self.build_data(params, ids_only=ids_only).alias() 778 | return sa.select( 779 | [sa.func.coalesce( 780 | sa.func.array_agg(data_query.c.data), 781 | json_array 782 | )], 783 | from_obj=data_query 784 | ).correlate(self.from_obj) 785 | 786 | 787 | class IncludeExpression(Expression): 788 | def build_included_union(self, params): 789 | selects = [ 790 | self.build_single_included(params.fields, subpath) 791 | for path in params.include 792 | for subpath in subpaths(path) 793 | ] 794 | 795 | union_select = union(*selects).alias() 796 | query = sa.select( 797 | [union_select.c.included.label('included')], 798 | from_obj=union_select 799 | ) 800 | if self.query_builder.sort_included: 801 | query = query.order_by( 802 | union_select.c.included[s('type')], 803 | union_select.c.included[s('id')] 804 | ) 805 | return query 806 | 807 | def build_included(self, params): 808 | included_union = self.build_included_union(params).alias() 809 | return sa.select( 810 | [sa.func.coalesce( 811 | sa.func.array_agg(included_union.c.included), 812 | jsonb_array 813 | ).label('included')], 814 | from_obj=included_union 815 | ) 816 | 817 | def build_single_included_fields(self, alias, fields): 818 | json_fields = self.query_builder.build_resource_identifier( 819 | alias, 820 | alias 821 | ) 822 | data_expr = DataExpression( 823 | self.query_builder, 824 | alias, 825 | sa.inspect(alias).selectable 826 | ) 827 | json_fields.extend( 828 | data_expr.build_attrs_relationships_and_links(fields) 829 | ) 830 | return json_fields 831 | 832 | def build_included_json_object(self, alias, fields): 833 | return sa.cast( 834 | sa.func.json_build_object( 835 | *self.build_single_included_fields(alias, fields) 836 | ), 837 | JSONB 838 | ).label('included') 839 | 840 | def build_single_included(self, fields, path): 841 | relationships = path_to_relationships(path, self.model) 842 | 843 | cls = relationships[-1].mapper.class_ 844 | subalias = sa.orm.aliased(cls) 845 | subquery = select_correlated_expression( 846 | self.model, 847 | subalias.id, 848 | path, 849 | subalias, 850 | self.from_obj, 851 | correlate=False 852 | ).with_only_columns(split_if_composite(subalias.id)).distinct() 853 | 854 | alias = sa.orm.aliased(cls) 855 | expr = self.build_included_json_object(alias, fields) 856 | query = sa.select( 857 | [expr], 858 | from_obj=alias 859 | ).where(alias.id.in_(subquery)).distinct() 860 | 861 | if cls is self.model: 862 | query = query.where( 863 | alias.id.notin_( 864 | sa.select( 865 | split_if_composite(get_attrs(self.from_obj).id), 866 | from_obj=self.from_obj 867 | ) 868 | ) 869 | ) 870 | return query 871 | 872 | 873 | def split_if_composite(column): 874 | if ( 875 | hasattr(column.comparator, 'expression') and 876 | isinstance(column.comparator.expression, CompositeId) 877 | ): 878 | return column.comparator.expression.keys 879 | return [column] 880 | -------------------------------------------------------------------------------- /sqlalchemy_json_api/utils.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | import sqlalchemy as sa 4 | from sqlalchemy.orm.attributes import InstrumentedAttribute, QueryableAttribute 5 | from sqlalchemy.sql.util import ClauseAdapter 6 | 7 | 8 | def adapt(adapt_with, expression): 9 | if isinstance(expression.expression, sa.Column): 10 | cols = get_attrs(adapt_with) 11 | return getattr(cols, expression.name) 12 | if not hasattr(adapt_with, 'is_derived_from'): 13 | adapt_with = sa.inspect(adapt_with).selectable 14 | return ClauseAdapter(adapt_with).traverse(expression.expression) 15 | 16 | 17 | def get_attrs(obj): 18 | if isinstance(obj, sa.orm.Mapper): 19 | return obj.class_ 20 | elif isinstance(obj, (sa.orm.util.AliasedClass, sa.orm.util.AliasedInsp)): 21 | return obj 22 | elif isinstance(obj, sa.sql.selectable.Selectable): 23 | return obj.c 24 | return obj 25 | 26 | 27 | def get_selectable(obj): 28 | if isinstance(obj, sa.sql.selectable.Selectable): 29 | return obj 30 | return sa.inspect(obj).selectable 31 | 32 | 33 | def subpaths(path): 34 | return [ 35 | '.'.join(path.split('.')[0:i + 1]) 36 | for i in range(len(path.split('.'))) 37 | ] 38 | 39 | 40 | def s(value): 41 | return sa.text("'{0}'".format(value)) 42 | 43 | 44 | def get_descriptor_columns(model, descriptor): 45 | if isinstance(descriptor, InstrumentedAttribute): 46 | return descriptor.property.columns 47 | elif isinstance(descriptor, sa.orm.ColumnProperty): 48 | return descriptor.columns 49 | elif isinstance(descriptor, sa.Column): 50 | return [descriptor] 51 | elif isinstance(descriptor, sa.sql.expression.ClauseElement): 52 | return [] 53 | elif isinstance(descriptor, sa.ext.hybrid.hybrid_property): 54 | expr = descriptor.expr(model) 55 | try: 56 | return get_descriptor_columns(model, expr) 57 | except TypeError: 58 | return [] 59 | elif ( 60 | isinstance(descriptor, QueryableAttribute) and 61 | hasattr(descriptor, 'original_property') 62 | ): 63 | return get_descriptor_columns(model, descriptor.property) 64 | raise TypeError( 65 | 'Given descriptor is not of type InstrumentedAttribute, ' 66 | 'ColumnProperty or Column.' 67 | ) 68 | 69 | 70 | def chain_if(*args): 71 | if args: 72 | return chain(*args) 73 | return [] 74 | 75 | 76 | def _included_sort_key(value): 77 | return (value['type'], value['id']) 78 | 79 | 80 | def assert_json_document(value, expected): 81 | assert value.keys() == expected.keys() 82 | for key in expected.keys(): 83 | if key == 'included': 84 | assert sorted( 85 | expected[key], 86 | key=_included_sort_key 87 | ) == sorted( 88 | value[key], 89 | key=_included_sort_key 90 | ) 91 | else: 92 | assert expected[key] == value[key] 93 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kvesteri/sqlalchemy-json-api/74b0e600fdb3e00b35057b4ab3f1959af56bdf44/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import pytest 4 | import sqlalchemy as sa 5 | from sqlalchemy import create_engine 6 | from sqlalchemy.ext.declarative import declarative_base 7 | from sqlalchemy.ext.hybrid import hybrid_property 8 | from sqlalchemy.orm import sessionmaker 9 | 10 | from sqlalchemy_json_api import CompositeId, QueryBuilder 11 | 12 | warnings.filterwarnings('error') 13 | 14 | 15 | @pytest.fixture(scope='class') 16 | def base(): 17 | return declarative_base() 18 | 19 | 20 | @pytest.fixture(scope='class') 21 | def group_user_cls(base): 22 | return sa.Table( 23 | 'group_user', 24 | base.metadata, 25 | sa.Column('user_id', sa.Integer, sa.ForeignKey('user.id')), 26 | sa.Column('group_id', sa.Integer, sa.ForeignKey('group.id')) 27 | ) 28 | 29 | 30 | @pytest.fixture(scope='class') 31 | def group_cls(base): 32 | class Group(base): 33 | __tablename__ = 'group' 34 | id = sa.Column(sa.Integer, primary_key=True) 35 | name = sa.Column(sa.String) 36 | 37 | return Group 38 | 39 | 40 | @pytest.fixture(scope='class') 41 | def organization_cls(base): 42 | class Organization(base): 43 | __tablename__ = 'organization' 44 | id = sa.Column(sa.Integer, primary_key=True) 45 | name = sa.Column(sa.String) 46 | 47 | return Organization 48 | 49 | 50 | @pytest.fixture(scope='class') 51 | def organization_membership_cls(base, organization_cls, user_cls): 52 | class OrganizationMembership(base): 53 | __tablename__ = 'organization_membership' 54 | organization_id = sa.Column( 55 | sa.Integer, 56 | sa.ForeignKey('organization.id'), 57 | primary_key=True 58 | ) 59 | user_id = sa.Column( 60 | sa.Integer, 61 | sa.ForeignKey('user.id'), 62 | primary_key=True 63 | ) 64 | user = sa.orm.relationship(user_cls, backref='memberships') 65 | organization = sa.orm.relationship(organization_cls, backref='members') 66 | 67 | is_admin = sa.Column( 68 | sa.Boolean, 69 | nullable=False, 70 | default=False, 71 | ) 72 | 73 | @hybrid_property 74 | def id(self): 75 | return CompositeId([self.organization_id, self.user_id]) 76 | 77 | return OrganizationMembership 78 | 79 | 80 | @pytest.fixture(scope='class') 81 | def friendship_cls(base): 82 | return sa.Table( 83 | 'friendships', 84 | base.metadata, 85 | sa.Column( 86 | 'friend_a_id', 87 | sa.Integer, 88 | sa.ForeignKey('user.id'), 89 | primary_key=True 90 | ), 91 | sa.Column( 92 | 'friend_b_id', 93 | sa.Integer, 94 | sa.ForeignKey('user.id'), 95 | primary_key=True 96 | ) 97 | ) 98 | 99 | 100 | @pytest.fixture(scope='class') 101 | def user_cls(base, group_user_cls, friendship_cls): 102 | class User(base): 103 | __tablename__ = 'user' 104 | id = sa.Column(sa.Integer, primary_key=True) 105 | name = sa.Column(sa.String) 106 | groups = sa.orm.relationship( 107 | 'Group', 108 | secondary=group_user_cls, 109 | backref='users' 110 | ) 111 | 112 | # this relationship is used for persistence 113 | friends = sa.orm.relationship( 114 | 'User', 115 | secondary=friendship_cls, 116 | primaryjoin=id == friendship_cls.c.friend_a_id, 117 | secondaryjoin=id == friendship_cls.c.friend_b_id, 118 | ) 119 | 120 | friendship_union = sa.select([ 121 | friendship_cls.c.friend_a_id, 122 | friendship_cls.c.friend_b_id 123 | ]).union( 124 | sa.select([ 125 | friendship_cls.c.friend_b_id, 126 | friendship_cls.c.friend_a_id] 127 | ) 128 | ).alias() 129 | 130 | User.all_friends = sa.orm.relationship( 131 | 'User', 132 | secondary=friendship_union, 133 | primaryjoin=User.id == friendship_union.c.friend_a_id, 134 | secondaryjoin=User.id == friendship_union.c.friend_b_id, 135 | viewonly=True, 136 | order_by=User.id 137 | ) 138 | return User 139 | 140 | 141 | @pytest.fixture(scope='class') 142 | def category_cls(base, group_user_cls, friendship_cls): 143 | class Category(base): 144 | __tablename__ = 'category' 145 | id = sa.Column(sa.Integer, primary_key=True) 146 | name = sa.Column(sa.String) 147 | created_at = sa.Column(sa.DateTime) 148 | parent_id = sa.Column(sa.Integer, sa.ForeignKey('category.id')) 149 | parent = sa.orm.relationship( 150 | 'Category', 151 | backref='subcategories', 152 | remote_side=[id], 153 | order_by=id 154 | ) 155 | return Category 156 | 157 | 158 | @pytest.fixture(scope='class') 159 | def article_cls(base, category_cls, user_cls): 160 | class Article(base): 161 | __tablename__ = 'article' 162 | id = sa.Column(sa.Integer, primary_key=True) 163 | _name = sa.Column('name', sa.String) 164 | name_synonym = sa.orm.synonym('name') 165 | 166 | @hybrid_property 167 | def name(self): 168 | return self._name 169 | 170 | @name.setter 171 | def name(self, name): 172 | self._name = name 173 | 174 | @hybrid_property 175 | def name_upper(self): 176 | return self.name.upper() if self.name else None 177 | 178 | @name_upper.expression 179 | def name_upper(cls): 180 | return sa.func.upper(cls.name) 181 | 182 | content = sa.Column(sa.String) 183 | 184 | category_id = sa.Column(sa.Integer, sa.ForeignKey(category_cls.id)) 185 | category = sa.orm.relationship(category_cls, backref='articles') 186 | 187 | author_id = sa.Column(sa.Integer, sa.ForeignKey(user_cls.id)) 188 | author = sa.orm.relationship( 189 | user_cls, 190 | primaryjoin=author_id == user_cls.id, 191 | backref='authored_articles' 192 | ) 193 | 194 | owner_id = sa.Column(sa.Integer, sa.ForeignKey(user_cls.id)) 195 | owner = sa.orm.relationship( 196 | user_cls, 197 | primaryjoin=owner_id == user_cls.id, 198 | backref='owned_articles' 199 | ) 200 | return Article 201 | 202 | 203 | @pytest.fixture(scope='class') 204 | def comment_cls(base, article_cls, user_cls): 205 | class Comment(base): 206 | __tablename__ = 'comment' 207 | id = sa.Column(sa.Integer, primary_key=True) 208 | content = sa.Column(sa.String) 209 | article_id = sa.Column(sa.Integer, sa.ForeignKey(article_cls.id)) 210 | article = sa.orm.relationship( 211 | article_cls, 212 | backref=sa.orm.backref('comments') 213 | ) 214 | 215 | author_id = sa.Column(sa.Integer, sa.ForeignKey(user_cls.id)) 216 | author = sa.orm.relationship(user_cls, backref='comments') 217 | 218 | article_cls.comment_count = sa.orm.column_property( 219 | sa.select([sa.func.count(Comment.id)]) 220 | .where(Comment.article_id == article_cls.id) 221 | .correlate(article_cls).label('comment_count') 222 | ) 223 | 224 | return Comment 225 | 226 | 227 | @pytest.fixture(scope='class') 228 | def composite_pk_cls(base): 229 | class CompositePKModel(base): 230 | __tablename__ = 'composite_pk_model' 231 | a = sa.Column(sa.Integer, primary_key=True) 232 | b = sa.Column(sa.Integer, primary_key=True) 233 | return CompositePKModel 234 | 235 | 236 | @pytest.fixture(scope='class') 237 | def dns(): 238 | return 'postgresql://postgres@localhost/sqlalchemy_json_api_test' 239 | 240 | 241 | @pytest.yield_fixture(scope='class') 242 | def engine(dns): 243 | engine = create_engine(dns) 244 | yield engine 245 | engine.dispose() 246 | 247 | 248 | @pytest.yield_fixture(scope='class') 249 | def connection(engine): 250 | conn = engine.connect() 251 | yield conn 252 | conn.close() 253 | 254 | 255 | @pytest.fixture(scope='class') 256 | def model_mapping( 257 | article_cls, 258 | category_cls, 259 | comment_cls, 260 | group_cls, 261 | user_cls, 262 | organization_cls, 263 | organization_membership_cls 264 | ): 265 | return { 266 | 'articles': article_cls, 267 | 'categories': category_cls, 268 | 'comments': comment_cls, 269 | 'groups': group_cls, 270 | 'users': user_cls, 271 | 'organizations': organization_cls, 272 | 'memberships': organization_membership_cls 273 | } 274 | 275 | 276 | @pytest.yield_fixture(scope='class') 277 | def table_creator(base, connection, model_mapping): 278 | sa.orm.configure_mappers() 279 | base.metadata.create_all(connection) 280 | yield 281 | base.metadata.drop_all(connection) 282 | 283 | 284 | @pytest.yield_fixture(scope='class') 285 | def session(connection): 286 | Session = sessionmaker(bind=connection) 287 | session = Session() 288 | yield session 289 | session.close_all() 290 | 291 | 292 | @pytest.fixture(scope='class') 293 | def dataset( 294 | session, 295 | user_cls, 296 | group_cls, 297 | article_cls, 298 | category_cls, 299 | comment_cls, 300 | organization_cls, 301 | organization_membership_cls 302 | ): 303 | organization = organization_cls(name='Organization 1') 304 | organization2 = organization_cls(name='Organization 2') 305 | organization3 = organization_cls(name='Organization 3') 306 | group = group_cls(name='Group 1') 307 | group2 = group_cls(name='Group 2') 308 | user = user_cls( 309 | id=1, 310 | name='User 1', 311 | groups=[group, group2], 312 | memberships=[ 313 | organization_membership_cls( 314 | organization=organization, 315 | is_admin=True 316 | ), 317 | organization_membership_cls( 318 | organization=organization2, 319 | is_admin=True 320 | ), 321 | organization_membership_cls( 322 | organization=organization3, 323 | is_admin=True 324 | ) 325 | ] 326 | ) 327 | user2 = user_cls(id=2, name='User 2') 328 | user3 = user_cls(id=3, name='User 3', groups=[group]) 329 | user4 = user_cls(id=4, name='User 4', groups=[group2]) 330 | user5 = user_cls(id=5, name='User 5') 331 | 332 | user.friends = [user2] 333 | user2.friends = [user3, user4] 334 | user3.friends = [user5] 335 | 336 | article = article_cls( 337 | name='Some article', 338 | author=user, 339 | owner=user2, 340 | category=category_cls( 341 | id=1, 342 | name='Some category', 343 | subcategories=[ 344 | category_cls( 345 | id=2, 346 | name='Subcategory 1', 347 | subcategories=[ 348 | category_cls( 349 | id=3, 350 | name='Subsubcategory 1', 351 | subcategories=[ 352 | category_cls( 353 | id=5, 354 | name='Subsubsubcategory 1', 355 | ), 356 | category_cls( 357 | id=6, 358 | name='Subsubsubcategory 2', 359 | ) 360 | ] 361 | ) 362 | ] 363 | ), 364 | category_cls(id=4, name='Subcategory 2'), 365 | ] 366 | ), 367 | comments=[ 368 | comment_cls( 369 | id=1, 370 | content='Comment 1', 371 | author=user 372 | ), 373 | comment_cls( 374 | id=2, 375 | content='Comment 2', 376 | author=user2 377 | ), 378 | comment_cls( 379 | id=3, 380 | content='Comment 3', 381 | author=user 382 | ), 383 | comment_cls( 384 | id=4, 385 | content='Comment 4', 386 | author=user2 387 | ) 388 | ] 389 | ) 390 | session.add(user3) 391 | session.add(user4) 392 | session.add(article) 393 | session.commit() 394 | 395 | 396 | @pytest.fixture 397 | def query_builder(model_mapping): 398 | return QueryBuilder(model_mapping) 399 | -------------------------------------------------------------------------------- /tests/test_select.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import sqlalchemy as sa 5 | 6 | from sqlalchemy_json_api import ( 7 | IdPropertyNotFound, 8 | InvalidField, 9 | QueryBuilder, 10 | RESERVED_KEYWORDS, 11 | UnknownField, 12 | UnknownFieldKey, 13 | UnknownModel 14 | ) 15 | 16 | 17 | @pytest.mark.usefixtures('table_creator', 'dataset') 18 | class TestQueryBuilderSelect(object): 19 | def test_throws_exception_for_unknown_fields_key(self, composite_pk_cls): 20 | with pytest.raises(IdPropertyNotFound) as e: 21 | QueryBuilder({'something': composite_pk_cls}) 22 | assert str(e.value) == ( 23 | "Couldn't find 'id' property for model {0}.".format( 24 | composite_pk_cls 25 | ) 26 | ) 27 | 28 | def test_throws_exception_for_model_without_id_property( 29 | self, 30 | query_builder, 31 | article_cls 32 | ): 33 | with pytest.raises(UnknownFieldKey) as e: 34 | query_builder.select(article_cls, fields={'bogus': []}) 35 | assert str(e.value) == ( 36 | "Unknown field keys given. Could not find key 'bogus' from " 37 | "given model mapping." 38 | ) 39 | 40 | def test_throws_exception_for_unknown_model(self, user_cls, article_cls): 41 | with pytest.raises(UnknownModel) as e: 42 | QueryBuilder({'users': user_cls}).select(article_cls) 43 | assert str(e.value) == ( 44 | "Unknown model given. Could not find model {0} from given " 45 | "model mapping.".format( 46 | article_cls 47 | ) 48 | ) 49 | 50 | def test_throws_exception_for_unknown_field( 51 | self, 52 | query_builder, 53 | article_cls 54 | ): 55 | with pytest.raises(UnknownField) as e: 56 | query_builder.select(article_cls, fields={'articles': ['bogus']}) 57 | assert str(e.value) == ( 58 | "Unknown field 'bogus'. Given selectable does not have " 59 | "descriptor named 'bogus'." 60 | ) 61 | 62 | @pytest.mark.parametrize( 63 | 'field', 64 | RESERVED_KEYWORDS 65 | ) 66 | def test_throws_exception_for_reserved_keyword( 67 | self, 68 | query_builder, 69 | article_cls, 70 | field 71 | ): 72 | with pytest.raises(InvalidField) as e: 73 | query_builder.select(article_cls, fields={'articles': [field]}) 74 | assert str(e.value) == ( 75 | "Given field '{0}' is reserved keyword.".format(field) 76 | ) 77 | 78 | def test_throws_exception_for_foreign_key_field( 79 | self, 80 | query_builder, 81 | article_cls 82 | ): 83 | with pytest.raises(InvalidField) as e: 84 | query_builder.select( 85 | article_cls, 86 | fields={'articles': ['author_id']} 87 | ) 88 | assert str(e.value) == ( 89 | "Field 'author_id' is invalid. The underlying column " 90 | "'author_id' has foreign key. You can't include foreign key " 91 | "attributes. Consider including relationship attributes." 92 | ) 93 | 94 | @pytest.mark.parametrize( 95 | ('fields', 'result'), 96 | ( 97 | ( 98 | None, 99 | { 100 | 'data': [{ 101 | 'id': '1', 102 | 'type': 'articles', 103 | 'attributes': { 104 | 'comment_count': 4, 105 | 'content': None, 106 | 'name': 'Some article', 107 | 'name_upper': 'SOME ARTICLE' 108 | }, 109 | 'relationships': { 110 | 'author': { 111 | 'data': {'id': '1', 'type': 'users'} 112 | }, 113 | 'category': { 114 | 'data': {'id': '1', 'type': 'categories'} 115 | }, 116 | 'comments': { 117 | 'data': [ 118 | {'id': '1', 'type': 'comments'}, 119 | {'id': '2', 'type': 'comments'}, 120 | {'id': '3', 'type': 'comments'}, 121 | {'id': '4', 'type': 'comments'}, 122 | ] 123 | }, 124 | 'owner': { 125 | 'data': {'id': '2', 'type': 'users'} 126 | } 127 | }, 128 | }] 129 | } 130 | ), 131 | ( 132 | {'articles': ['name', 'content']}, 133 | { 134 | 'data': [{ 135 | 'type': 'articles', 136 | 'id': '1', 137 | 'attributes': { 138 | 'name': 'Some article', 139 | 'content': None 140 | } 141 | }] 142 | } 143 | ), 144 | ( 145 | {'articles': ['name']}, 146 | { 147 | 'data': [{ 148 | 'type': 'articles', 149 | 'id': '1', 150 | 'attributes': { 151 | 'name': 'Some article' 152 | } 153 | }] 154 | } 155 | ), 156 | ( 157 | {'articles': ['name', 'content', 'category']}, 158 | { 159 | 'data': [{ 160 | 'type': 'articles', 161 | 'id': '1', 162 | 'attributes': { 163 | 'name': 'Some article', 164 | 'content': None 165 | }, 166 | 'relationships': { 167 | 'category': { 168 | 'data': {'type': 'categories', 'id': '1'} 169 | } 170 | } 171 | }] 172 | } 173 | ), 174 | ( 175 | {'articles': ['name', 'content', 'comments']}, 176 | { 177 | 'data': [{ 178 | 'type': 'articles', 179 | 'id': '1', 180 | 'attributes': { 181 | 'name': 'Some article', 182 | 'content': None 183 | }, 184 | 'relationships': { 185 | 'comments': { 186 | 'data': [ 187 | {'type': 'comments', 'id': '1'}, 188 | {'type': 'comments', 'id': '2'}, 189 | {'type': 'comments', 'id': '3'}, 190 | {'type': 'comments', 'id': '4'} 191 | ] 192 | } 193 | } 194 | }] 195 | } 196 | ), 197 | ( 198 | {'articles': ['name', 'content', 'comments', 'category']}, 199 | { 200 | 'data': [{ 201 | 'type': 'articles', 202 | 'id': '1', 203 | 'attributes': { 204 | 'name': 'Some article', 205 | 'content': None 206 | }, 207 | 'relationships': { 208 | 'comments': { 209 | 'data': [ 210 | {'type': 'comments', 'id': '1'}, 211 | {'type': 'comments', 'id': '2'}, 212 | {'type': 'comments', 'id': '3'}, 213 | {'type': 'comments', 'id': '4'} 214 | ] 215 | }, 216 | 'category': { 217 | 'data': {'type': 'categories', 'id': '1'} 218 | } 219 | } 220 | }] 221 | } 222 | ) 223 | ) 224 | ) 225 | def test_fields_parameter( 226 | self, 227 | query_builder, 228 | session, 229 | article_cls, 230 | fields, 231 | result 232 | ): 233 | query = query_builder.select(article_cls, fields=fields) 234 | assert session.execute(query).scalar() == result 235 | 236 | def test_association_class_relationship_fetching( 237 | self, 238 | query_builder, 239 | session, 240 | user_cls, 241 | organization_membership_cls 242 | ): 243 | query = query_builder.select( 244 | user_cls, 245 | fields={'users': ['memberships']}, 246 | sort=['id'], 247 | from_obj=session.query(user_cls).filter(user_cls.id == 1) 248 | ) 249 | assert session.execute(query).scalar() == { 250 | 'data': [ 251 | { 252 | 'relationships': { 253 | 'memberships': { 254 | 'data': [ 255 | {'type': 'memberships', 'id': '1:1'}, 256 | {'type': 'memberships', 'id': '2:1'}, 257 | {'type': 'memberships', 'id': '3:1'} 258 | ] 259 | } 260 | }, 261 | 'type': 'users', 262 | 'id': '1' 263 | }, 264 | ] 265 | } 266 | 267 | def test_association_class_relationship_fetching_with_include( 268 | self, 269 | query_builder, 270 | session, 271 | article_cls 272 | ): 273 | query = query_builder.select( 274 | article_cls, 275 | fields={ 276 | 'articles': ['author'], 277 | 'users': ['memberships'] 278 | }, 279 | include=['author'] 280 | ) 281 | assert session.execute(query).scalar() == { 282 | 'included': [ 283 | { 284 | 'relationships': { 285 | 'memberships': { 286 | 'data': [ 287 | {'type': 'memberships', 'id': '1:1'}, 288 | {'type': 'memberships', 'id': '2:1'}, 289 | {'type': 'memberships', 'id': '3:1'} 290 | ] 291 | } 292 | }, 293 | 'type': 'users', 294 | 'id': '1' 295 | } 296 | ], 297 | 'data': [ 298 | { 299 | 'relationships': { 300 | 'author': { 301 | 'data': {'type': 'users', 'id': '1'} 302 | } 303 | }, 304 | 'type': 'articles', 305 | 'id': '1' 306 | } 307 | ] 308 | } 309 | 310 | def test_custom_order_by_for_relationship( 311 | self, 312 | query_builder, 313 | session, 314 | article_cls, 315 | comment_cls 316 | ): 317 | article_cls.comments.property.order_by = [sa.desc(comment_cls.id)] 318 | query = query_builder.select( 319 | article_cls, 320 | fields={'articles': ['comments']} 321 | ) 322 | assert session.execute(query).scalar() == { 323 | 'data': [{ 324 | 'type': 'articles', 325 | 'id': '1', 326 | 'relationships': { 327 | 'comments': { 328 | 'data': [ 329 | {'type': 'comments', 'id': '4'}, 330 | {'type': 'comments', 'id': '3'}, 331 | {'type': 'comments', 'id': '2'}, 332 | {'type': 'comments', 'id': '1'} 333 | ] 334 | } 335 | } 336 | }] 337 | } 338 | 339 | def test_custom_order_by_for_m2m_relationship( 340 | self, 341 | query_builder, 342 | session, 343 | user_cls, 344 | group_user_cls 345 | ): 346 | user_cls.groups.property.order_by = [ 347 | sa.desc(group_user_cls.c.group_id) 348 | ] 349 | from_obj = session.query(user_cls).filter(user_cls.id == 1) 350 | query = query_builder.select( 351 | user_cls, 352 | from_obj=from_obj, 353 | fields={'users': ['groups']} 354 | ) 355 | assert session.execute(query).scalar() == { 356 | 'data': [{ 357 | 'type': 'users', 358 | 'id': '1', 359 | 'relationships': { 360 | 'groups': { 361 | 'data': [ 362 | {'type': 'groups', 'id': '2'}, 363 | {'type': 'groups', 'id': '1'}, 364 | ] 365 | } 366 | } 367 | }] 368 | } 369 | user_cls.groups.property.order_by = [group_user_cls.c.group_id] 370 | query = query_builder.select( 371 | user_cls, 372 | from_obj=from_obj, 373 | fields={'users': ['groups']} 374 | ) 375 | assert session.execute(query).scalar() == { 376 | 'data': [{ 377 | 'type': 'users', 378 | 'id': '1', 379 | 'relationships': { 380 | 'groups': { 381 | 'data': [ 382 | {'type': 'groups', 'id': '1'}, 383 | {'type': 'groups', 'id': '2'}, 384 | ] 385 | } 386 | } 387 | }] 388 | } 389 | 390 | @pytest.mark.parametrize( 391 | ('fields', 'result'), 392 | ( 393 | ( 394 | {'articles': ['comment_count']}, 395 | { 396 | 'data': [{ 397 | 'type': 'articles', 398 | 'id': '1', 399 | 'attributes': { 400 | 'comment_count': 4 401 | } 402 | }] 403 | } 404 | ), 405 | ) 406 | ) 407 | def test_fields_parameter_with_column_property( 408 | self, 409 | query_builder, 410 | session, 411 | article_cls, 412 | fields, 413 | result 414 | ): 415 | query = query_builder.select(article_cls, fields=fields) 416 | assert session.execute(query).scalar() == result 417 | 418 | @pytest.mark.parametrize( 419 | ('fields', 'result'), 420 | ( 421 | ( 422 | {'articles': ['comment_count']}, 423 | { 424 | 'data': [{ 425 | 'type': 'articles', 426 | 'id': '1', 427 | 'attributes': { 428 | 'comment_count': 4 429 | } 430 | }] 431 | } 432 | ), 433 | ) 434 | ) 435 | def test_column_property_with_custom_from_obj( 436 | self, 437 | query_builder, 438 | session, 439 | article_cls, 440 | fields, 441 | result 442 | ): 443 | from_obj = session.query(article_cls).with_entities( 444 | article_cls.id, 445 | article_cls.comment_count 446 | ) 447 | query = query_builder.select( 448 | article_cls, 449 | from_obj=from_obj, 450 | fields=fields 451 | ) 452 | assert session.execute(query).scalar() == result 453 | 454 | @pytest.mark.parametrize( 455 | ('fields', 'result'), 456 | ( 457 | ( 458 | {'articles': ['name_upper']}, 459 | { 460 | 'data': [{ 461 | 'type': 'articles', 462 | 'id': '1', 463 | 'attributes': { 464 | 'name_upper': 'SOME ARTICLE' 465 | } 466 | }] 467 | } 468 | ), 469 | ) 470 | ) 471 | def test_fields_parameter_with_hybrid_property( 472 | self, 473 | query_builder, 474 | session, 475 | article_cls, 476 | fields, 477 | result 478 | ): 479 | query = query_builder.select(article_cls, fields=fields) 480 | assert session.execute(query).scalar() == result 481 | 482 | @pytest.mark.parametrize( 483 | ('fields', 'should_contain_sql', 'result'), 484 | ( 485 | ( 486 | {'articles': ['name_upper']}, 487 | 'upper(main_query.name)', 488 | { 489 | 'data': [{ 490 | 'type': 'articles', 491 | 'id': '1', 492 | 'attributes': { 493 | 'name_upper': 'SOME ARTICLE' 494 | } 495 | }] 496 | } 497 | ), 498 | ( 499 | {'articles': ['name']}, 500 | 'main_query.name', 501 | { 502 | 'data': [{ 503 | 'type': 'articles', 504 | 'id': '1', 505 | 'attributes': { 506 | 'name': 'Some article' 507 | } 508 | }] 509 | } 510 | ), 511 | ) 512 | ) 513 | def test_hybrid_property_inclusion_uses_clause_adaptation( 514 | self, 515 | query_builder, 516 | session, 517 | article_cls, 518 | fields, 519 | result, 520 | should_contain_sql 521 | ): 522 | query = query_builder.select( 523 | article_cls, 524 | fields=fields, 525 | from_obj=session.query(article_cls) 526 | ) 527 | compiled = query.compile(dialect=sa.dialects.postgresql.dialect()) 528 | assert should_contain_sql in str(compiled) 529 | assert session.execute(query).scalar() == result 530 | 531 | @pytest.mark.parametrize( 532 | ('fields', 'include', 'result'), 533 | ( 534 | ( 535 | {'users': []}, 536 | ['groups'], 537 | {'data': [], 'included': []} 538 | ), 539 | ( 540 | {'users': []}, 541 | None, 542 | {'data': []} 543 | ), 544 | ) 545 | ) 546 | def test_empty_data( 547 | self, 548 | query_builder, 549 | session, 550 | user_cls, 551 | fields, 552 | include, 553 | result 554 | ): 555 | query = query_builder.select( 556 | user_cls, 557 | fields=fields, 558 | include=include, 559 | from_obj=session.query(user_cls).filter(user_cls.id == 99) 560 | ) 561 | assert session.execute(query).scalar() == result 562 | 563 | def test_fetch_multiple_results(self, query_builder, session, user_cls): 564 | query = query_builder.select( 565 | user_cls, 566 | fields={'users': ['all_friends']}, 567 | from_obj=( 568 | session.query(user_cls) 569 | .filter(user_cls.id.in_([1, 2])) 570 | .order_by(user_cls.id) 571 | ) 572 | ) 573 | assert session.execute(query).scalar() == { 574 | 'data': [ 575 | { 576 | 'relationships': { 577 | 'all_friends': {'data': [{'id': '2', 'type': 'users'}]} 578 | }, 579 | 'id': '1', 580 | 'type': 'users' 581 | }, 582 | { 583 | 'relationships': { 584 | 'all_friends': { 585 | 'data': [ 586 | {'id': '1', 'type': 'users'}, 587 | {'id': '3', 'type': 'users'}, 588 | {'id': '4', 'type': 'users'} 589 | ] 590 | } 591 | }, 592 | 'id': '2', 593 | 'type': 'users' 594 | } 595 | ] 596 | } 597 | 598 | def test_as_text_parameter(self, query_builder, session, article_cls): 599 | query = query_builder.select( 600 | article_cls, 601 | fields={'articles': ['name']}, 602 | as_text=True 603 | ) 604 | 605 | assert json.loads(session.execute(query).scalar()) == { 606 | 'data': [{ 607 | 'type': 'articles', 608 | 'id': '1', 609 | 'attributes': { 610 | 'name': 'Some article' 611 | } 612 | }] 613 | } 614 | 615 | @pytest.mark.parametrize( 616 | ('limit', 'offset', 'result'), 617 | ( 618 | ( 619 | 3, 620 | 0, 621 | [ 622 | { 623 | 'id': '1', 624 | 'type': 'users' 625 | }, 626 | { 627 | 'id': '2', 628 | 'type': 'users' 629 | }, 630 | { 631 | 'id': '3', 632 | 'type': 'users' 633 | } 634 | ] 635 | ), 636 | ( 637 | 3, 638 | 2, 639 | [ 640 | { 641 | 'id': '3', 642 | 'type': 'users' 643 | }, 644 | { 645 | 'id': '4', 646 | 'type': 'users' 647 | }, 648 | { 649 | 'id': '5', 650 | 'type': 'users' 651 | } 652 | ] 653 | ), 654 | ( 655 | 1, 656 | 5, 657 | [] 658 | ), 659 | ) 660 | ) 661 | def test_limit_and_offset( 662 | self, 663 | query_builder, 664 | session, 665 | user_cls, 666 | limit, 667 | offset, 668 | result 669 | ): 670 | query = query_builder.select( 671 | user_cls, 672 | sort=['id'], 673 | fields={'users': []}, 674 | limit=limit, 675 | offset=offset 676 | ) 677 | assert session.execute(query).scalar() == { 678 | 'data': result 679 | } 680 | 681 | @pytest.mark.parametrize( 682 | ('limit', 'offset', 'result'), 683 | ( 684 | ( 685 | 1, 686 | 0, 687 | { 688 | 'data': [ 689 | { 690 | 'id': '1', 691 | 'type': 'users' 692 | } 693 | ], 694 | 'included': [ 695 | { 696 | 'id': '1', 697 | 'type': 'groups' 698 | }, 699 | { 700 | 'id': '2', 701 | 'type': 'groups' 702 | } 703 | ] 704 | } 705 | ), 706 | ( 707 | 1, 708 | 1, 709 | { 710 | 'data': [ 711 | { 712 | 'id': '2', 713 | 'type': 'users' 714 | } 715 | ], 716 | 'included': [] 717 | } 718 | ), 719 | ( 720 | 1, 721 | 5, 722 | {'data': [], 'included': []} 723 | ), 724 | ) 725 | ) 726 | def test_limit_and_offset_with_included( 727 | self, 728 | query_builder, 729 | session, 730 | user_cls, 731 | limit, 732 | offset, 733 | result 734 | ): 735 | query = query_builder.select( 736 | user_cls, 737 | sort=['id'], 738 | fields={'users': [], 'groups': []}, 739 | include={'groups'}, 740 | limit=limit, 741 | offset=offset 742 | ) 743 | assert session.execute(query).scalar() == result 744 | 745 | def test_hybrid_property_inspection_from_query( 746 | self, 747 | query_builder, 748 | session, 749 | organization_membership_cls 750 | ): 751 | query = query_builder.select( 752 | organization_membership_cls, 753 | from_obj=session.query(organization_membership_cls) 754 | ) 755 | assert session.execute(query).scalar() == { 756 | 'data': [ 757 | { 758 | 'relationships': { 759 | 'organization': { 760 | 'data': {'type': 'organizations', 'id': '1'} 761 | }, 762 | 'user': {'data': {'type': 'users', 'id': '1'}} 763 | }, 764 | 'attributes': {'is_admin': True}, 765 | 'type': 'memberships', 766 | 'id': '1:1' 767 | }, 768 | { 769 | 'relationships': { 770 | 'organization': { 771 | 'data': {'type': 'organizations', 'id': '2'} 772 | }, 773 | 'user': {'data': {'type': 'users', 'id': '1'}} 774 | }, 775 | 'attributes': {'is_admin': True}, 776 | 'type': 'memberships', 777 | 'id': '2:1' 778 | }, 779 | { 780 | 'relationships': { 781 | 'organization': { 782 | 'data': {'type': 'organizations', 'id': '3'} 783 | }, 784 | 'user': {'data': {'type': 'users', 'id': '1'}} 785 | }, 786 | 'attributes': {'is_admin': True}, 787 | 'type': 'memberships', 'id': '3:1' 788 | } 789 | ] 790 | } 791 | -------------------------------------------------------------------------------- /tests/test_select_one.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.usefixtures('table_creator', 'dataset') 7 | class TestSelectOne(object): 8 | def test_with_from_obj(self, query_builder, session, user_cls): 9 | query = query_builder.select_one( 10 | user_cls, 11 | 1, 12 | fields={'users': ['all_friends']}, 13 | from_obj=session.query(user_cls) 14 | ) 15 | assert session.execute(query).scalar() == { 16 | 'data': { 17 | 'relationships': { 18 | 'all_friends': {'data': [{'id': '2', 'type': 'users'}]} 19 | }, 20 | 'id': '1', 21 | 'type': 'users' 22 | } 23 | } 24 | 25 | def test_without_from_obj(self, query_builder, session, user_cls): 26 | query = query_builder.select_one( 27 | user_cls, 28 | 1, 29 | fields={'users': ['all_friends']}, 30 | ) 31 | assert session.execute(query).scalar() == { 32 | 'data': { 33 | 'relationships': { 34 | 'all_friends': {'data': [{'id': '2', 'type': 'users'}]} 35 | }, 36 | 'id': '1', 37 | 'type': 'users' 38 | } 39 | } 40 | 41 | def test_empty_result(self, query_builder, session, user_cls): 42 | query = query_builder.select_one( 43 | user_cls, 44 | 99, 45 | ) 46 | assert session.execute(query).scalar() is None 47 | 48 | def test_as_text_parameter(self, query_builder, session, article_cls): 49 | query = query_builder.select_one( 50 | article_cls, 51 | 1, 52 | fields={'articles': ['name']}, 53 | as_text=True 54 | ) 55 | 56 | assert json.loads(session.execute(query).scalar()) == { 57 | 'data': { 58 | 'type': 'articles', 59 | 'id': '1', 60 | 'attributes': { 61 | 'name': 'Some article' 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/test_select_related.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | 5 | from sqlalchemy_json_api import QueryBuilder 6 | 7 | 8 | @pytest.mark.usefixtures('table_creator', 'dataset') 9 | class TestSelectRelated(object): 10 | @pytest.mark.parametrize( 11 | ('id', 'result'), 12 | ( 13 | ( 14 | 1, 15 | {'data': [{'type': 'users', 'id': '2'}]} 16 | ), 17 | ( 18 | 2, 19 | {'data': [ 20 | {'type': 'users', 'id': '1'}, 21 | {'type': 'users', 'id': '3'}, 22 | {'type': 'users', 'id': '4'} 23 | ]} 24 | ) 25 | ) 26 | ) 27 | def test_to_many_relationship_with_ids_only( 28 | self, 29 | query_builder, 30 | session, 31 | user_cls, 32 | id, 33 | result 34 | ): 35 | query = query_builder.select_related( 36 | session.query(user_cls).get(id), 37 | 'all_friends', 38 | fields={'users': []} 39 | ) 40 | assert session.execute(query).scalar() == result 41 | 42 | @pytest.mark.parametrize( 43 | ('id', 'fields', 'result'), 44 | ( 45 | ( 46 | 2, 47 | {'categories': ['name']}, 48 | {'data': { 49 | 'type': 50 | 'categories', 51 | 'id': '1', 52 | 'attributes': { 53 | 'name': 'Some category' 54 | } 55 | }} 56 | ), 57 | ( 58 | 5, 59 | {'categories': ['parent']}, 60 | {'data': { 61 | 'type': 'categories', 62 | 'id': '3', 63 | 'relationships': { 64 | 'parent': { 65 | 'data': { 66 | 'id': '2', 67 | 'type': 'categories' 68 | } 69 | } 70 | } 71 | }} 72 | ) 73 | ) 74 | ) 75 | def test_to_one_relationship( 76 | self, 77 | query_builder, 78 | session, 79 | category_cls, 80 | id, 81 | fields, 82 | result 83 | ): 84 | query = query_builder.select_related( 85 | session.query(category_cls).get(id), 86 | 'parent', 87 | fields=fields 88 | ) 89 | assert session.execute(query).scalar() == result 90 | 91 | 92 | @pytest.mark.usefixtures('table_creator', 'dataset') 93 | class TestSelectRelationshipWithLinks(object): 94 | @pytest.fixture 95 | def query_builder(self, model_mapping): 96 | return QueryBuilder(model_mapping, base_url='/') 97 | 98 | @pytest.mark.parametrize( 99 | ('id', 'links', 'result'), 100 | ( 101 | ( 102 | 1, 103 | {'self': '/users/1/all_friends'}, 104 | { 105 | 'data': [ 106 | { 107 | 'type': 'users', 108 | 'id': '2', 109 | 'links': {'self': '/users/2'} 110 | } 111 | ], 112 | 'links': {'self': '/users/1/all_friends'} 113 | } 114 | ), 115 | ( 116 | 2, 117 | {'self': '/users/2/all_friends'}, 118 | { 119 | 'data': [ 120 | { 121 | 'type': 'users', 122 | 'id': '1', 123 | 'links': {'self': '/users/1'} 124 | }, 125 | { 126 | 'type': 'users', 127 | 'id': '3', 128 | 'links': {'self': '/users/3'} 129 | }, 130 | { 131 | 'type': 'users', 132 | 'id': '4', 133 | 'links': {'self': '/users/4'} 134 | } 135 | ], 136 | 'links': { 137 | 'self': '/users/2/all_friends', 138 | } 139 | } 140 | ) 141 | ) 142 | ) 143 | def test_to_many_relationship( 144 | self, 145 | query_builder, 146 | session, 147 | user_cls, 148 | id, 149 | links, 150 | result 151 | ): 152 | query = query_builder.select_related( 153 | session.query(user_cls).get(id), 154 | 'all_friends', 155 | fields={'users': []}, 156 | links=links 157 | ) 158 | assert session.execute(query).scalar() == result 159 | 160 | @pytest.mark.parametrize( 161 | ('id', 'links', 'result'), 162 | ( 163 | ( 164 | 2, 165 | {'self': '/categories/2/parent'}, 166 | { 167 | 'data': { 168 | 'type': 'categories', 169 | 'id': '1', 170 | 'links': {'self': '/categories/1'} 171 | }, 172 | 'links': {'self': '/categories/2/parent'} 173 | } 174 | ), 175 | ( 176 | 5, 177 | {'self': '/categories/5/parent'}, 178 | { 179 | 'data': { 180 | 'type': 'categories', 181 | 'id': '3', 182 | 'links': {'self': '/categories/3'} 183 | }, 184 | 'links': {'self': '/categories/5/parent'} 185 | } 186 | ) 187 | ) 188 | ) 189 | def test_to_one_parent_child_relationship( 190 | self, 191 | query_builder, 192 | session, 193 | category_cls, 194 | id, 195 | result, 196 | links 197 | ): 198 | query = query_builder.select_related( 199 | session.query(category_cls).get(id), 200 | 'parent', 201 | fields={'categories': []}, 202 | links=links 203 | ) 204 | assert session.execute(query).scalar() == result 205 | 206 | @pytest.mark.parametrize( 207 | ('id', 'links', 'result'), 208 | ( 209 | ( 210 | 1, 211 | { 212 | 'self': '/articles/1/category', 213 | }, 214 | { 215 | 'data': { 216 | 'type': 'categories', 217 | 'id': '1', 218 | 'links': { 219 | 'self': '/categories/1', 220 | } 221 | }, 222 | 'links': { 223 | 'self': '/articles/1/category', 224 | } 225 | } 226 | ), 227 | ) 228 | ) 229 | def test_to_one_relationship( 230 | self, 231 | query_builder, 232 | session, 233 | article_cls, 234 | id, 235 | links, 236 | result 237 | ): 238 | query = query_builder.select_related( 239 | session.query(article_cls).get(id), 240 | 'category', 241 | fields={'categories': []}, 242 | links=links 243 | ) 244 | assert session.execute(query).scalar() == result 245 | 246 | @pytest.mark.parametrize( 247 | ('id', 'result'), 248 | ( 249 | ( 250 | 1, 251 | { 252 | 'data': { 253 | 'type': 'categories', 254 | 'id': '1', 255 | 'links': { 256 | 'self': '/categories/1', 257 | } 258 | } 259 | } 260 | ), 261 | ) 262 | ) 263 | def test_as_text_parameter( 264 | self, 265 | query_builder, 266 | session, 267 | article_cls, 268 | id, 269 | result 270 | ): 271 | query = query_builder.select_related( 272 | session.query(article_cls).get(id), 273 | 'category', 274 | fields={'categories': []}, 275 | as_text=True 276 | ) 277 | assert json.loads(session.execute(query).scalar()) == result 278 | 279 | @pytest.mark.parametrize( 280 | ('id', 'result'), 281 | ( 282 | ( 283 | 1, 284 | {'data': None} 285 | ), 286 | ) 287 | ) 288 | def test_empty_result( 289 | self, 290 | query_builder, 291 | session, 292 | category_cls, 293 | id, 294 | result 295 | ): 296 | query = query_builder.select_related( 297 | session.query(category_cls).get(id), 298 | 'parent', 299 | fields={'categories': []}, 300 | session=session 301 | ) 302 | assert session.execute(query).scalar() == result 303 | 304 | @pytest.mark.parametrize( 305 | ('id', 'result'), 306 | ( 307 | ( 308 | 1, 309 | {'data': None} 310 | ), 311 | ) 312 | ) 313 | def test_empty_result_as_text( 314 | self, 315 | query_builder, 316 | session, 317 | category_cls, 318 | id, 319 | result 320 | ): 321 | query = query_builder.select_related( 322 | session.query(category_cls).get(id), 323 | 'parent', 324 | fields={'categories': []}, 325 | session=session, 326 | as_text=True 327 | ) 328 | assert json.loads(session.execute(query).scalar()) == result 329 | -------------------------------------------------------------------------------- /tests/test_select_relationship.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | 5 | from sqlalchemy_json_api import QueryBuilder 6 | 7 | 8 | @pytest.mark.usefixtures('table_creator', 'dataset') 9 | class TestSelectRelationship(object): 10 | @pytest.mark.parametrize( 11 | ('id', 'result'), 12 | ( 13 | ( 14 | 1, 15 | {'data': [{'type': 'users', 'id': '2'}]} 16 | ), 17 | ( 18 | 2, 19 | {'data': [ 20 | {'type': 'users', 'id': '1'}, 21 | {'type': 'users', 'id': '3'}, 22 | {'type': 'users', 'id': '4'} 23 | ]} 24 | ) 25 | ) 26 | ) 27 | def test_to_many_relationship( 28 | self, 29 | query_builder, 30 | session, 31 | user_cls, 32 | id, 33 | result 34 | ): 35 | query = query_builder.select_relationship( 36 | session.query(user_cls).get(id), 37 | 'all_friends' 38 | ) 39 | assert session.execute(query).scalar() == result 40 | 41 | @pytest.mark.parametrize( 42 | ('id', 'result'), 43 | ( 44 | ( 45 | 2, 46 | {'data': {'type': 'categories', 'id': '1'}} 47 | ), 48 | ( 49 | 5, 50 | {'data': {'type': 'categories', 'id': '3'}} 51 | ) 52 | ) 53 | ) 54 | def test_to_one_relationship( 55 | self, 56 | query_builder, 57 | session, 58 | category_cls, 59 | id, 60 | result 61 | ): 62 | query = query_builder.select_relationship( 63 | session.query(category_cls).get(id), 64 | 'parent' 65 | ) 66 | assert session.execute(query).scalar() == result 67 | 68 | 69 | @pytest.mark.usefixtures('table_creator', 'dataset') 70 | class TestSelectRelationshipWithLinks(object): 71 | @pytest.fixture 72 | def query_builder(self, model_mapping): 73 | return QueryBuilder(model_mapping, base_url='/') 74 | 75 | @pytest.mark.parametrize( 76 | ('id', 'links', 'result'), 77 | ( 78 | ( 79 | 1, 80 | { 81 | 'self': '/users/1/relationships/all_friends', 82 | 'related': '/users/1/all_friends' 83 | }, 84 | { 85 | 'data': [ 86 | { 87 | 'type': 'users', 88 | 'id': '2', 89 | } 90 | ], 91 | 'links': { 92 | 'self': '/users/1/relationships/all_friends', 93 | 'related': '/users/1/all_friends' 94 | } 95 | } 96 | ), 97 | ( 98 | 2, 99 | { 100 | 'self': '/users/2/relationships/all_friends', 101 | 'related': '/users/2/all_friends' 102 | }, 103 | { 104 | 'data': [ 105 | { 106 | 'type': 'users', 107 | 'id': '1', 108 | }, 109 | { 110 | 'type': 'users', 111 | 'id': '3', 112 | }, 113 | { 114 | 'type': 'users', 115 | 'id': '4', 116 | } 117 | ], 118 | 'links': { 119 | 'self': '/users/2/relationships/all_friends', 120 | 'related': '/users/2/all_friends' 121 | } 122 | } 123 | ) 124 | ) 125 | ) 126 | def test_to_many_relationship( 127 | self, 128 | query_builder, 129 | session, 130 | user_cls, 131 | id, 132 | links, 133 | result 134 | ): 135 | query = query_builder.select_relationship( 136 | session.query(user_cls).get(id), 137 | 'all_friends', 138 | links=links 139 | ) 140 | assert session.execute(query).scalar() == result 141 | 142 | @pytest.mark.parametrize( 143 | ('id', 'links', 'result'), 144 | ( 145 | ( 146 | 2, 147 | { 148 | 'self': '/categories/2/relationships/parent', 149 | 'related': '/categories/2/parent' 150 | }, 151 | { 152 | 'data': { 153 | 'type': 'categories', 154 | 'id': '1', 155 | }, 156 | 'links': { 157 | 'self': '/categories/2/relationships/parent', 158 | 'related': '/categories/2/parent' 159 | } 160 | } 161 | ), 162 | ( 163 | 5, 164 | { 165 | 'self': '/categories/5/relationships/parent', 166 | 'related': '/categories/5/parent' 167 | }, 168 | { 169 | 'data': { 170 | 'type': 'categories', 171 | 'id': '3', 172 | }, 173 | 'links': { 174 | 'self': '/categories/5/relationships/parent', 175 | 'related': '/categories/5/parent' 176 | } 177 | } 178 | ) 179 | ) 180 | ) 181 | def test_to_one_parent_child_relationship( 182 | self, 183 | query_builder, 184 | session, 185 | category_cls, 186 | id, 187 | result, 188 | links 189 | ): 190 | query = query_builder.select_relationship( 191 | session.query(category_cls).get(id), 192 | 'parent', 193 | links=links 194 | ) 195 | assert session.execute(query).scalar() == result 196 | 197 | @pytest.mark.parametrize( 198 | ('id', 'links', 'result'), 199 | ( 200 | ( 201 | 1, 202 | { 203 | 'self': '/articles/1/relationships/category', 204 | 'related': '/articles/1/category' 205 | }, 206 | { 207 | 'data': { 208 | 'type': 'categories', 209 | 'id': '1', 210 | }, 211 | 'links': { 212 | 'self': '/articles/1/relationships/category', 213 | 'related': '/articles/1/category' 214 | } 215 | } 216 | ), 217 | ) 218 | ) 219 | def test_to_one_relationship( 220 | self, 221 | query_builder, 222 | session, 223 | article_cls, 224 | id, 225 | links, 226 | result 227 | ): 228 | query = query_builder.select_relationship( 229 | session.query(article_cls).get(id), 230 | 'category', 231 | links=links 232 | ) 233 | assert session.execute(query).scalar() == result 234 | 235 | @pytest.mark.parametrize( 236 | ('id', 'result'), 237 | ( 238 | ( 239 | 1, 240 | { 241 | 'data': { 242 | 'type': 'categories', 243 | 'id': '1', 244 | } 245 | } 246 | ), 247 | ) 248 | ) 249 | def test_as_text_parameter( 250 | self, 251 | query_builder, 252 | session, 253 | article_cls, 254 | id, 255 | result 256 | ): 257 | query = query_builder.select_relationship( 258 | session.query(article_cls).get(id), 259 | 'category', 260 | as_text=True 261 | ) 262 | assert json.loads(session.execute(query).scalar()) == result 263 | -------------------------------------------------------------------------------- /tests/test_select_with_include.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sqlalchemy_json_api import assert_json_document 4 | 5 | 6 | @pytest.mark.usefixtures('table_creator', 'dataset') 7 | class TestQueryBuilderSelectWithInclude(object): 8 | @pytest.mark.parametrize( 9 | ('fields', 'include', 'result'), 10 | ( 11 | ( 12 | {'users': []}, 13 | ['all_friends'], 14 | { 15 | 'data': [ 16 | {'type': 'users', 'id': '1'}, 17 | {'type': 'users', 'id': '2'} 18 | ], 19 | 'included': [ 20 | {'type': 'users', 'id': '3'}, 21 | {'type': 'users', 'id': '4'} 22 | ] 23 | } 24 | ), 25 | ) 26 | ) 27 | def test_with_many_root_entities( 28 | self, 29 | query_builder, 30 | session, 31 | user_cls, 32 | fields, 33 | include, 34 | result 35 | ): 36 | query = query_builder.select( 37 | user_cls, 38 | fields=fields, 39 | include=include, 40 | from_obj=session.query(user_cls).filter(user_cls.id.in_([1, 2])) 41 | ) 42 | assert session.execute(query).scalar() == result 43 | 44 | @pytest.mark.parametrize( 45 | ('fields', 'include', 'result'), 46 | ( 47 | ( 48 | {'comments': [], 'users': []}, 49 | ['author'], 50 | { 51 | 'data': [ 52 | {'type': 'comments', 'id': '1'}, 53 | {'type': 'comments', 'id': '2'}, 54 | {'type': 'comments', 'id': '3'}, 55 | {'type': 'comments', 'id': '4'} 56 | ], 57 | 'included': [ 58 | {'type': 'users', 'id': '1'}, 59 | {'type': 'users', 'id': '2'} 60 | ] 61 | } 62 | ), 63 | ( 64 | { 65 | 'comments': [], 66 | 'users': [], 67 | 'memberships': [] 68 | }, 69 | ['author.memberships'], 70 | { 71 | 'included': [ 72 | {'type': 'memberships', 'id': '1:1'}, 73 | {'type': 'memberships', 'id': '2:1'}, 74 | {'type': 'memberships', 'id': '3:1'}, 75 | {'type': 'users', 'id': '1'}, 76 | {'type': 'users', 'id': '2'} 77 | ], 78 | 'data': [ 79 | {'type': 'comments', 'id': '1'}, 80 | {'type': 'comments', 'id': '2'}, 81 | {'type': 'comments', 'id': '3'}, 82 | {'type': 'comments', 'id': '4'} 83 | ] 84 | } 85 | ), 86 | ) 87 | ) 88 | def test_fetches_distinct_included_resources( 89 | self, 90 | query_builder, 91 | session, 92 | comment_cls, 93 | fields, 94 | include, 95 | result 96 | ): 97 | query = query_builder.select( 98 | comment_cls, 99 | fields=fields, 100 | include=include, 101 | from_obj=session.query(comment_cls).order_by(comment_cls.id) 102 | ) 103 | assert session.execute(query).scalar() == result 104 | 105 | @pytest.mark.parametrize( 106 | ('fields', 'include', 'result'), 107 | ( 108 | ( 109 | {'articles': ['name', 'content', 'category']}, 110 | ['category'], 111 | { 112 | 'data': [{ 113 | 'type': 'articles', 114 | 'id': '1', 115 | 'attributes': { 116 | 'name': 'Some article', 117 | 'content': None 118 | }, 119 | 'relationships': { 120 | 'category': { 121 | 'data': {'type': 'categories', 'id': '1'} 122 | } 123 | } 124 | }], 125 | 'included': [{ 126 | 'type': 'categories', 127 | 'id': '1', 128 | 'attributes': { 129 | 'created_at': None, 130 | 'name': 'Some category' 131 | }, 132 | 'relationships': { 133 | 'articles': { 134 | 'data': [{'type': 'articles', 'id': '1'}] 135 | }, 136 | 'subcategories': { 137 | 'data': [ 138 | {'type': 'categories', 'id': '2'}, 139 | {'type': 'categories', 'id': '4'} 140 | ] 141 | }, 142 | 'parent': {'data': None} 143 | } 144 | }] 145 | } 146 | ), 147 | ( 148 | {'articles': [], 'categories': ['name']}, 149 | ['category'], 150 | { 151 | 'data': [{ 152 | 'type': 'articles', 153 | 'id': '1', 154 | }], 155 | 'included': [{ 156 | 'type': 'categories', 157 | 'id': '1', 158 | 'attributes': { 159 | 'name': 'Some category' 160 | } 161 | }] 162 | } 163 | ), 164 | ( 165 | {'articles': ['category'], 'categories': ['name']}, 166 | ['category'], 167 | { 168 | 'data': [{ 169 | 'type': 'articles', 170 | 'id': '1', 171 | 'relationships': { 172 | 'category': { 173 | 'data': {'type': 'categories', 'id': '1'} 174 | } 175 | } 176 | }], 177 | 'included': [{ 178 | 'type': 'categories', 179 | 'id': '1', 180 | 'attributes': { 181 | 'name': 'Some category' 182 | } 183 | }] 184 | } 185 | ), 186 | ( 187 | { 188 | 'articles': ['name', 'content', 'category'], 189 | 'categories': ['name'] 190 | }, 191 | ['category'], 192 | { 193 | 'data': [{ 194 | 'type': 'articles', 195 | 'id': '1', 196 | 'attributes': { 197 | 'name': 'Some article', 198 | 'content': None 199 | }, 200 | 'relationships': { 201 | 'category': { 202 | 'data': {'type': 'categories', 'id': '1'} 203 | } 204 | } 205 | }], 206 | 'included': [{ 207 | 'type': 'categories', 208 | 'id': '1', 209 | 'attributes': { 210 | 'name': 'Some category' 211 | } 212 | }] 213 | } 214 | ), 215 | ( 216 | { 217 | 'articles': ['name', 'content', 'category', 'comments'], 218 | 'categories': ['name'], 219 | 'comments': ['content'] 220 | }, 221 | ['category', 'comments'], 222 | { 223 | 'data': [{ 224 | 'type': 'articles', 225 | 'id': '1', 226 | 'attributes': { 227 | 'name': 'Some article', 228 | 'content': None 229 | }, 230 | 'relationships': { 231 | 'category': { 232 | 'data': {'type': 'categories', 'id': '1'} 233 | }, 234 | 'comments': { 235 | 'data': [ 236 | {'type': 'comments', 'id': '1'}, 237 | {'type': 'comments', 'id': '2'}, 238 | {'type': 'comments', 'id': '3'}, 239 | {'type': 'comments', 'id': '4'} 240 | ] 241 | } 242 | } 243 | }], 244 | 'included': [ 245 | { 246 | 'type': 'categories', 247 | 'id': '1', 248 | 'attributes': {'name': 'Some category'} 249 | }, 250 | { 251 | 'type': 'comments', 252 | 'id': '1', 253 | 'attributes': {'content': 'Comment 1'} 254 | }, 255 | { 256 | 'type': 'comments', 257 | 'id': '2', 258 | 'attributes': {'content': 'Comment 2'} 259 | }, 260 | { 261 | 'type': 'comments', 262 | 'id': '3', 263 | 'attributes': {'content': 'Comment 3'} 264 | }, 265 | { 266 | 'type': 'comments', 267 | 'id': '4', 268 | 'attributes': {'content': 'Comment 4'} 269 | }, 270 | ] 271 | } 272 | ), 273 | ) 274 | ) 275 | def test_include_parameter( 276 | self, 277 | query_builder, 278 | session, 279 | article_cls, 280 | fields, 281 | include, 282 | result 283 | ): 284 | query = query_builder.select( 285 | article_cls, 286 | fields=fields, 287 | include=include 288 | ) 289 | assert session.execute(query).scalar() == result 290 | 291 | @pytest.mark.parametrize( 292 | ('fields', 'include', 'result'), 293 | ( 294 | ( 295 | { 296 | 'articles': ['name', 'content', 'category'], 297 | 'categories': ['name', 'subcategories'], 298 | }, 299 | ['category.subcategories'], 300 | { 301 | 'data': [{ 302 | 'type': 'articles', 303 | 'id': '1', 304 | 'attributes': { 305 | 'name': 'Some article', 306 | 'content': None 307 | }, 308 | 'relationships': { 309 | 'category': { 310 | 'data': {'type': 'categories', 'id': '1'} 311 | } 312 | } 313 | }], 314 | 'included': [ 315 | { 316 | 'type': 'categories', 317 | 'id': '1', 318 | 'attributes': {'name': 'Some category'}, 319 | 'relationships': { 320 | 'subcategories': { 321 | 'data': [ 322 | {'type': 'categories', 'id': '2'}, 323 | {'type': 'categories', 'id': '4'} 324 | ] 325 | } 326 | } 327 | }, 328 | { 329 | 'type': 'categories', 330 | 'id': '2', 331 | 'attributes': {'name': 'Subcategory 1'}, 332 | 'relationships': { 333 | 'subcategories': { 334 | 'data': [{'type': 'categories', 'id': '3'}] 335 | } 336 | } 337 | }, 338 | { 339 | 'type': 'categories', 340 | 'id': '4', 341 | 'attributes': {'name': 'Subcategory 2'}, 342 | 'relationships': { 343 | 'subcategories': { 344 | 'data': [] 345 | } 346 | } 347 | }, 348 | ] 349 | } 350 | ), 351 | ( 352 | { 353 | 'articles': ['name', 'content', 'category'], 354 | 'categories': ['name', 'subcategories'], 355 | }, 356 | ['category.subcategories.subcategories'], 357 | { 358 | 'data': [{ 359 | 'type': 'articles', 360 | 'id': '1', 361 | 'attributes': { 362 | 'name': 'Some article', 363 | 'content': None 364 | }, 365 | 'relationships': { 366 | 'category': { 367 | 'data': {'type': 'categories', 'id': '1'} 368 | } 369 | } 370 | }], 371 | 'included': [ 372 | { 373 | 'type': 'categories', 374 | 'id': '1', 375 | 'attributes': {'name': 'Some category'}, 376 | 'relationships': { 377 | 'subcategories': { 378 | 'data': [ 379 | {'type': 'categories', 'id': '2'}, 380 | {'type': 'categories', 'id': '4'} 381 | ] 382 | } 383 | } 384 | }, 385 | { 386 | 'type': 'categories', 387 | 'id': '2', 388 | 'attributes': {'name': 'Subcategory 1'}, 389 | 'relationships': { 390 | 'subcategories': { 391 | 'data': [{'type': 'categories', 'id': '3'}] 392 | } 393 | } 394 | }, 395 | { 396 | 'type': 'categories', 397 | 'id': '3', 398 | 'attributes': {'name': 'Subsubcategory 1'}, 399 | 'relationships': { 400 | 'subcategories': { 401 | 'data': [ 402 | {'type': 'categories', 'id': '5'}, 403 | {'type': 'categories', 'id': '6'} 404 | ] 405 | } 406 | } 407 | }, 408 | { 409 | 'type': 'categories', 410 | 'id': '4', 411 | 'attributes': {'name': 'Subcategory 2'}, 412 | 'relationships': { 413 | 'subcategories': { 414 | 'data': [] 415 | } 416 | } 417 | }, 418 | ] 419 | } 420 | ), 421 | ) 422 | ) 423 | def test_deep_relationships( 424 | self, 425 | query_builder, 426 | session, 427 | article_cls, 428 | fields, 429 | include, 430 | result 431 | ): 432 | query = query_builder.select( 433 | article_cls, 434 | fields=fields, 435 | include=include 436 | ) 437 | assert session.execute(query).scalar() == result 438 | 439 | @pytest.mark.parametrize( 440 | ('fields', 'include', 'result'), 441 | ( 442 | ( 443 | { 444 | 'users': ['name', 'all_friends'], 445 | }, 446 | ['all_friends'], 447 | { 448 | 'data': [{ 449 | 'type': 'users', 450 | 'id': '1', 451 | 'attributes': { 452 | 'name': 'User 1', 453 | }, 454 | 'relationships': { 455 | 'all_friends': { 456 | 'data': [ 457 | {'type': 'users', 'id': '2'} 458 | ] 459 | } 460 | } 461 | }], 462 | 'included': [ 463 | { 464 | 'type': 'users', 465 | 'id': '2', 466 | 'attributes': {'name': 'User 2'}, 467 | 'relationships': { 468 | 'all_friends': { 469 | 'data': [ 470 | {'type': 'users', 'id': '1'}, 471 | {'type': 'users', 'id': '3'}, 472 | {'type': 'users', 'id': '4'} 473 | ] 474 | } 475 | } 476 | }, 477 | ] 478 | } 479 | ), 480 | ( 481 | { 482 | 'users': ['name', 'all_friends'], 483 | }, 484 | ['all_friends.all_friends'], 485 | { 486 | 'data': [{ 487 | 'type': 'users', 488 | 'id': '1', 489 | 'attributes': { 490 | 'name': 'User 1', 491 | }, 492 | 'relationships': { 493 | 'all_friends': { 494 | 'data': [ 495 | {'type': 'users', 'id': '2'} 496 | ] 497 | } 498 | } 499 | }], 500 | 'included': [ 501 | { 502 | 'type': 'users', 503 | 'id': '2', 504 | 'attributes': {'name': 'User 2'}, 505 | 'relationships': { 506 | 'all_friends': { 507 | 'data': [ 508 | {'type': 'users', 'id': '1'}, 509 | {'type': 'users', 'id': '3'}, 510 | {'type': 'users', 'id': '4'} 511 | ] 512 | } 513 | } 514 | }, 515 | { 516 | 'type': 'users', 517 | 'id': '3', 518 | 'attributes': {'name': 'User 3'}, 519 | 'relationships': { 520 | 'all_friends': { 521 | 'data': [ 522 | {'type': 'users', 'id': '2'}, 523 | {'type': 'users', 'id': '5'} 524 | ] 525 | } 526 | } 527 | }, 528 | { 529 | 'type': 'users', 530 | 'id': '4', 531 | 'attributes': {'name': 'User 4'}, 532 | 'relationships': { 533 | 'all_friends': { 534 | 'data': [ 535 | {'type': 'users', 'id': '2'} 536 | ] 537 | } 538 | } 539 | }, 540 | ] 541 | } 542 | ), 543 | ( 544 | { 545 | 'users': ['name', 'all_friends'], 546 | }, 547 | ['all_friends.all_friends.all_friends'], 548 | { 549 | 'data': [{ 550 | 'type': 'users', 551 | 'id': '1', 552 | 'attributes': { 553 | 'name': 'User 1', 554 | }, 555 | 'relationships': { 556 | 'all_friends': { 557 | 'data': [ 558 | {'type': 'users', 'id': '2'} 559 | ] 560 | } 561 | } 562 | }], 563 | 'included': [ 564 | { 565 | 'type': 'users', 566 | 'id': '2', 567 | 'attributes': {'name': 'User 2'}, 568 | 'relationships': { 569 | 'all_friends': { 570 | 'data': [ 571 | {'type': 'users', 'id': '1'}, 572 | {'type': 'users', 'id': '3'}, 573 | {'type': 'users', 'id': '4'} 574 | ] 575 | } 576 | } 577 | }, 578 | { 579 | 'type': 'users', 580 | 'id': '3', 581 | 'attributes': {'name': 'User 3'}, 582 | 'relationships': { 583 | 'all_friends': { 584 | 'data': [ 585 | {'type': 'users', 'id': '2'}, 586 | {'type': 'users', 'id': '5'} 587 | ] 588 | } 589 | } 590 | }, 591 | { 592 | 'type': 'users', 593 | 'id': '4', 594 | 'attributes': {'name': 'User 4'}, 595 | 'relationships': { 596 | 'all_friends': { 597 | 'data': [ 598 | {'type': 'users', 'id': '2'} 599 | ] 600 | } 601 | } 602 | }, 603 | { 604 | 'type': 'users', 605 | 'id': '5', 606 | 'attributes': {'name': 'User 5'}, 607 | 'relationships': { 608 | 'all_friends': { 609 | 'data': [ 610 | {'type': 'users', 'id': '3'} 611 | ] 612 | } 613 | } 614 | }, 615 | ] 616 | } 617 | ), 618 | ) 619 | ) 620 | def test_self_referencing_m2m( 621 | self, 622 | query_builder, 623 | session, 624 | user_cls, 625 | fields, 626 | include, 627 | result 628 | ): 629 | query = query_builder.select( 630 | user_cls, 631 | fields=fields, 632 | include=include, 633 | from_obj=session.query(user_cls).filter( 634 | user_cls.id == 1 635 | ) 636 | ) 637 | assert session.execute(query).scalar() == result 638 | 639 | @pytest.mark.parametrize( 640 | ('fields', 'include', 'result'), 641 | ( 642 | ( 643 | { 644 | 'users': ['groups'], 645 | }, 646 | ['groups'], 647 | { 648 | 'data': [{ 649 | 'type': 'users', 650 | 'id': '5', 651 | 'relationships': { 652 | 'groups': { 653 | 'data': [] 654 | } 655 | } 656 | }], 657 | 'included': [] 658 | } 659 | ), 660 | ) 661 | ) 662 | def test_included_as_empty( 663 | self, 664 | query_builder, 665 | session, 666 | user_cls, 667 | fields, 668 | include, 669 | result 670 | ): 671 | query = query_builder.select( 672 | user_cls, 673 | fields=fields, 674 | include=include, 675 | from_obj=session.query(user_cls).filter( 676 | user_cls.id == 5 677 | ) 678 | ) 679 | assert session.execute(query).scalar() == result 680 | 681 | def test_hybrid_property_in_included_object( 682 | self, 683 | query_builder, 684 | session, 685 | category_cls, 686 | comment_cls 687 | ): 688 | query = query_builder.select( 689 | category_cls, 690 | fields={'articles': ['comment_count']}, 691 | include=['articles'], 692 | from_obj=session.query(category_cls).filter( 693 | category_cls.id == 1 694 | ) 695 | ) 696 | assert session.execute(query).scalar() == { 697 | 'included': [ 698 | { 699 | 'attributes': {'comment_count': 4}, 700 | 'type': 'articles', 701 | 'id': '1' 702 | } 703 | ], 704 | 'data': [ 705 | { 706 | 'relationships': { 707 | 'articles': { 708 | 'data': [{'type': 'articles', 'id': '1'}] 709 | }, 710 | 'subcategories': { 711 | 'data': [ 712 | {'type': 'categories', 'id': '2'}, 713 | {'type': 'categories', 'id': '4'} 714 | ] 715 | }, 716 | 'parent': {'data': None} 717 | }, 718 | 'attributes': { 719 | 'created_at': None, 720 | 'name': 'Some category' 721 | }, 722 | 'type': 'categories', 723 | 'id': '1' 724 | } 725 | ] 726 | } 727 | 728 | @pytest.mark.parametrize( 729 | ('fields', 'include', 'result'), 730 | ( 731 | ( 732 | { 733 | 'users': [], 734 | }, 735 | [], 736 | { 737 | 'data': [{ 738 | 'type': 'users', 739 | 'id': '5' 740 | }] 741 | } 742 | ), 743 | ) 744 | ) 745 | def test_empty_list_as_included( 746 | self, 747 | query_builder, 748 | session, 749 | user_cls, 750 | fields, 751 | include, 752 | result 753 | ): 754 | query = query_builder.select( 755 | user_cls, 756 | fields=fields, 757 | include=include, 758 | from_obj=session.query(user_cls).filter( 759 | user_cls.id == 5 760 | ) 761 | ) 762 | assert session.execute(query).scalar() == result 763 | 764 | @pytest.mark.parametrize( 765 | ('fields', 'include', 'result'), 766 | ( 767 | ( 768 | { 769 | 'articles': ['name', 'content', 'category'], 770 | 'categories': ['name', 'subcategories'], 771 | }, 772 | ['category.subcategories.subcategories'], 773 | { 774 | 'data': [{ 775 | 'type': 'articles', 776 | 'id': '1', 777 | 'attributes': { 778 | 'name': 'Some article', 779 | 'content': None 780 | }, 781 | 'relationships': { 782 | 'category': { 783 | 'data': {'type': 'categories', 'id': '1'} 784 | } 785 | } 786 | }], 787 | 'included': [ 788 | { 789 | 'type': 'categories', 790 | 'id': '1', 791 | 'attributes': {'name': 'Some category'}, 792 | 'relationships': { 793 | 'subcategories': { 794 | 'data': [ 795 | {'type': 'categories', 'id': '2'}, 796 | {'type': 'categories', 'id': '4'} 797 | ] 798 | } 799 | } 800 | }, 801 | { 802 | 'type': 'categories', 803 | 'id': '2', 804 | 'attributes': {'name': 'Subcategory 1'}, 805 | 'relationships': { 806 | 'subcategories': { 807 | 'data': [{'type': 'categories', 'id': '3'}] 808 | } 809 | } 810 | }, 811 | { 812 | 'type': 'categories', 813 | 'id': '3', 814 | 'attributes': {'name': 'Subsubcategory 1'}, 815 | 'relationships': { 816 | 'subcategories': { 817 | 'data': [ 818 | {'type': 'categories', 'id': '5'}, 819 | {'type': 'categories', 'id': '6'} 820 | ] 821 | } 822 | } 823 | }, 824 | { 825 | 'type': 'categories', 826 | 'id': '4', 827 | 'attributes': {'name': 'Subcategory 2'}, 828 | 'relationships': { 829 | 'subcategories': { 830 | 'data': [] 831 | } 832 | } 833 | }, 834 | ] 835 | } 836 | ), 837 | ) 838 | ) 839 | def test_sort_included_as_false( 840 | self, 841 | query_builder, 842 | session, 843 | article_cls, 844 | fields, 845 | include, 846 | result 847 | ): 848 | query_builder.sort_included = False 849 | query = query_builder.select( 850 | article_cls, 851 | fields=fields, 852 | include=include 853 | ) 854 | assert_json_document(session.execute(query).scalar(), result) 855 | -------------------------------------------------------------------------------- /tests/test_select_with_links.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sqlalchemy_json_api import QueryBuilder 4 | 5 | 6 | @pytest.fixture 7 | def query_builder(model_mapping): 8 | return QueryBuilder(model_mapping, base_url='/') 9 | 10 | 11 | @pytest.mark.usefixtures('table_creator', 'dataset') 12 | class TestQueryBuilderSelectWithLinks(object): 13 | def test_root_data_links(self, session, article_cls, query_builder): 14 | query = query_builder.select(article_cls, fields={'articles': []}) 15 | result = { 16 | 'data': [ 17 | { 18 | 'id': '1', 19 | 'type': 'articles', 20 | 'links': { 21 | 'self': '/articles/1' 22 | } 23 | } 24 | ] 25 | } 26 | assert session.execute(query).scalar() == result 27 | 28 | @pytest.mark.parametrize( 29 | ('fields', 'include', 'result'), 30 | ( 31 | ( 32 | { 33 | 'articles': ['name', 'content', 'category'], 34 | 'categories': [] 35 | }, 36 | ['category'], 37 | { 38 | 'data': [{ 39 | 'type': 'articles', 40 | 'id': '1', 41 | 'attributes': { 42 | 'name': 'Some article', 43 | 'content': None 44 | }, 45 | 'links': {'self': '/articles/1'}, 46 | 'relationships': { 47 | 'category': { 48 | 'data': { 49 | 'type': 'categories', 50 | 'id': '1', 51 | }, 52 | 'links': { 53 | 'self': ( 54 | '/articles/1/relationships/category' 55 | ), 56 | 'related': '/articles/1/category' 57 | } 58 | } 59 | } 60 | }], 61 | 'included': [{ 62 | 'type': 'categories', 63 | 'id': '1', 64 | 'links': {'self': '/categories/1'} 65 | }] 66 | } 67 | ), 68 | ( 69 | {'articles': [], 'categories': ['name', 'subcategories']}, 70 | ['category'], 71 | { 72 | 'data': [{ 73 | 'type': 'articles', 74 | 'id': '1', 75 | 'links': {'self': '/articles/1'}, 76 | }], 77 | 'included': [{ 78 | 'type': 'categories', 79 | 'id': '1', 80 | 'attributes': { 81 | 'name': 'Some category' 82 | }, 83 | 'links': {'self': '/categories/1'}, 84 | 'relationships': { 85 | 'subcategories': { 86 | 'links': { 87 | 'self': ( 88 | '/categories/1/relationships' 89 | '/subcategories' 90 | ), 91 | 'related': '/categories/1/subcategories' 92 | }, 93 | 'data': [ 94 | {'id': '2', 'type': 'categories'}, 95 | {'id': '4', 'type': 'categories'} 96 | ] 97 | } 98 | }, 99 | }] 100 | } 101 | ), 102 | ) 103 | ) 104 | def test_links_with_relationships_and_include( 105 | self, 106 | query_builder, 107 | session, 108 | article_cls, 109 | fields, 110 | include, 111 | result 112 | ): 113 | query = query_builder.select( 114 | article_cls, 115 | fields=fields, 116 | include=include 117 | ) 118 | assert session.execute(query).scalar() == result 119 | -------------------------------------------------------------------------------- /tests/test_select_with_sort.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture(scope='class') 5 | def dataset(session, category_cls): 6 | session.add_all([ 7 | category_cls(name='Category A', id=1), 8 | category_cls(name='Category A', id=2), 9 | category_cls(name='Category A', id=3), 10 | category_cls(name='Category B', id=4), 11 | category_cls(name='Category B', id=5), 12 | category_cls(name='Category B', id=6) 13 | ]) 14 | session.commit() 15 | 16 | 17 | @pytest.mark.usefixtures('table_creator', 'dataset') 18 | class TestQueryBuilderSelectWithSorting(object): 19 | @pytest.mark.parametrize( 20 | ('sort', 'result'), 21 | ( 22 | ( 23 | ['id'], 24 | {'data': [ 25 | {'type': 'categories', 'id': '1'}, 26 | {'type': 'categories', 'id': '2'}, 27 | {'type': 'categories', 'id': '3'}, 28 | {'type': 'categories', 'id': '4'}, 29 | {'type': 'categories', 'id': '5'}, 30 | {'type': 'categories', 'id': '6'} 31 | ]} 32 | ), 33 | ( 34 | ['-id'], 35 | {'data': [ 36 | {'type': 'categories', 'id': '6'}, 37 | {'type': 'categories', 'id': '5'}, 38 | {'type': 'categories', 'id': '4'}, 39 | {'type': 'categories', 'id': '3'}, 40 | {'type': 'categories', 'id': '2'}, 41 | {'type': 'categories', 'id': '1'} 42 | ]} 43 | ), 44 | ( 45 | ['name', 'id'], 46 | {'data': [ 47 | {'type': 'categories', 'id': '1'}, 48 | {'type': 'categories', 'id': '2'}, 49 | {'type': 'categories', 'id': '3'}, 50 | {'type': 'categories', 'id': '4'}, 51 | {'type': 'categories', 'id': '5'}, 52 | {'type': 'categories', 'id': '6'} 53 | ]} 54 | ), 55 | ( 56 | ['name', '-id'], 57 | {'data': [ 58 | {'type': 'categories', 'id': '3'}, 59 | {'type': 'categories', 'id': '2'}, 60 | {'type': 'categories', 'id': '1'}, 61 | {'type': 'categories', 'id': '6'}, 62 | {'type': 'categories', 'id': '5'}, 63 | {'type': 'categories', 'id': '4'} 64 | ]} 65 | ), 66 | ( 67 | ['-name', 'id'], 68 | {'data': [ 69 | {'type': 'categories', 'id': '4'}, 70 | {'type': 'categories', 'id': '5'}, 71 | {'type': 'categories', 'id': '6'}, 72 | {'type': 'categories', 'id': '1'}, 73 | {'type': 'categories', 'id': '2'}, 74 | {'type': 'categories', 'id': '3'} 75 | ]} 76 | ), 77 | ) 78 | ) 79 | def test_sort_root_resource( 80 | self, 81 | session, 82 | query_builder, 83 | category_cls, 84 | sort, 85 | result 86 | ): 87 | query = query_builder.select( 88 | category_cls, 89 | fields={'categories': []}, 90 | sort=sort 91 | ) 92 | assert session.execute(query).scalar() == result 93 | -------------------------------------------------------------------------------- /tests/test_type_formatters.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | import sqlalchemy as sa 5 | 6 | 7 | def isoformat(date): 8 | return sa.func.to_char( 9 | date, 10 | sa.text('\'YYYY-MM-DD"T"HH24:MI:SS.US"Z"\'') 11 | ).label(date.name) 12 | 13 | 14 | @pytest.mark.usefixtures('table_creator') 15 | class TestTypeFormatters(object): 16 | @pytest.fixture 17 | def category(self, session, category_cls): 18 | category = category_cls( 19 | name='Category', created_at=datetime(2011, 1, 1) 20 | ) 21 | session.add(category) 22 | session.commit() 23 | return category 24 | 25 | def test_formats_columns_with_matching_types( 26 | self, 27 | query_builder, 28 | category, 29 | session, 30 | category_cls 31 | ): 32 | query_builder.type_formatters = { 33 | sa.DateTime: isoformat 34 | } 35 | query = query_builder.select_one( 36 | category_cls, 37 | 1, 38 | fields={'categories': ['created_at', 'name']}, 39 | from_obj=session.query(category_cls) 40 | ) 41 | assert session.execute(query).scalar() == { 42 | 'data': { 43 | 'attributes': { 44 | 'created_at': '2011-01-01T00:00:00.000000Z', 45 | 'name': 'Category' 46 | }, 47 | 'id': '1', 48 | 'type': 'categories' 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sqlalchemy_json_api import assert_json_document 4 | 5 | 6 | @pytest.mark.parametrize( 7 | ('value', 'expected'), 8 | ( 9 | ( 10 | { 11 | 'data': [{'type': 'articles', 'id': '1'}], 12 | 'included': [ 13 | { 14 | 'type': 'categories', 15 | 'id': '2', 16 | }, 17 | { 18 | 'type': 'categories', 19 | 'id': '1', 20 | }, 21 | ] 22 | }, 23 | { 24 | 'data': [{ 25 | 'type': 'articles', 26 | 'id': '1', 27 | }], 28 | 'included': [ 29 | { 30 | 'type': 'categories', 31 | 'id': '1', 32 | }, 33 | { 34 | 'type': 'categories', 35 | 'id': '2', 36 | }, 37 | ] 38 | } 39 | ), 40 | ) 41 | ) 42 | def test_assert_json_document_for_matching_documents(value, expected): 43 | assert_json_document(value, expected) 44 | --------------------------------------------------------------------------------