├── .github └── workflows │ ├── deploy-demo.yml │ ├── publish.yml │ ├── readme-toc.yaml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── datasette_graphql ├── __init__.py ├── hookspecs.py ├── static │ ├── LICENSES.md │ ├── fetch_latest.py │ ├── graphiql.1.5.1.min.css │ ├── graphiql.1.5.1.min.js │ ├── react-dom.production.17.0.2.min.js │ └── react.production.17.0.2.min.js ├── templates │ └── graphiql.html └── utils.py ├── examples ├── base64.md ├── basic.md ├── filters.md ├── fragments.md ├── nested.md ├── nullable.md ├── related.md ├── related_multiple.md ├── search.md ├── sort.md ├── sort_desc.md ├── sql_view.md ├── table_row.md ├── variables.md └── where.md ├── pytest.ini ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── fixtures.py ├── test_docs.py ├── test_graphql.py ├── test_plugin_hook.py ├── test_schema_caching.py ├── test_schema_for_database.py ├── test_template_tag.py └── test_utils.py /.github/workflows/deploy-demo.yml: -------------------------------------------------------------------------------- 1 | name: Deploy demo 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up Python 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: 3.9 17 | - uses: actions/cache@v2 18 | name: Configure pip caching 19 | with: 20 | path: ~/.cache/pip 21 | key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} 22 | restore-keys: | 23 | ${{ runner.os }}-pip- 24 | - name: Download database 25 | run: | 26 | curl -O https://github-to-sqlite.dogsheep.net/github.db 27 | - name: Install Python dependencies 28 | run: | 29 | pip install -e .[test] 30 | - name: Generate fixtures.db 31 | run: | 32 | python tests/fixtures.py fixtures.db 33 | - name: Create Metadata 34 | run: | 35 | echo '{ 36 | "title": "datasette-graphql demo", 37 | "about": "simonw/datasette-graphql", 38 | "about_url": "https://github.com/simonw/datasette-graphql", 39 | "description_html": "Try it out at /graphql", 40 | "databases": { 41 | "fixtures": { 42 | "tables": { 43 | "repos": { 44 | "plugins": { 45 | "datasette-graphql": { 46 | "json_columns": ["tags"] 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | }' > metadata.json 54 | - name: Set up Cloud Run 55 | uses: google-github-actions/setup-gcloud@v0 56 | with: 57 | version: '318.0.0' 58 | service_account_email: ${{ secrets.GCP_SA_EMAIL }} 59 | service_account_key: ${{ secrets.GCP_SA_KEY }} 60 | - name: Deploy to Cloud Run 61 | run: |- 62 | gcloud config set run/region us-central1 63 | gcloud config set project datasette-222320 64 | datasette publish cloudrun github.db fixtures.db \ 65 | -m metadata.json \ 66 | --install=https://github.com/simonw/datasette-graphql/archive/$GITHUB_SHA.zip \ 67 | --install=datasette-schema-versions \ 68 | --service datasette-graphql-demo 69 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - uses: actions/cache@v2 20 | name: Configure pip caching 21 | with: 22 | path: ~/.cache/pip 23 | key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} 24 | restore-keys: | 25 | ${{ runner.os }}-pip- 26 | - name: Install dependencies 27 | run: | 28 | pip install -e '.[test]' 29 | - name: Run tests 30 | run: | 31 | pytest 32 | deploy: 33 | runs-on: ubuntu-latest 34 | needs: [test] 35 | steps: 36 | - uses: actions/checkout@v2 37 | - name: Set up Python 38 | uses: actions/setup-python@v2 39 | with: 40 | python-version: '3.11' 41 | - uses: actions/cache@v2 42 | name: Configure pip caching 43 | with: 44 | path: ~/.cache/pip 45 | key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }} 46 | restore-keys: | 47 | ${{ runner.os }}-publish-pip- 48 | - name: Install dependencies 49 | run: | 50 | pip install setuptools wheel twine build 51 | - name: Publish 52 | env: 53 | TWINE_USERNAME: __token__ 54 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 55 | run: | 56 | python -m build 57 | twine upload dist/* 58 | -------------------------------------------------------------------------------- /.github/workflows/readme-toc.yaml: -------------------------------------------------------------------------------- 1 | name: Update README table of contents 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | paths: 9 | - README.md 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out repo 16 | uses: actions/checkout@v2 17 | - name: Update TOC 18 | run: npx markdown-toc README.md -i 19 | - name: Commit and push if README changed 20 | run: |- 21 | git diff 22 | git config --global user.email "readme-bot@example.com" 23 | git config --global user.name "README-bot" 24 | git diff --quiet || (git add README.md && git commit -m "Updated README") 25 | git push 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - uses: actions/cache@v2 18 | name: Configure pip caching 19 | with: 20 | path: ~/.cache/pip 21 | key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} 22 | restore-keys: | 23 | ${{ runner.os }}-pip- 24 | - name: Install dependencies 25 | run: | 26 | pip install -e '.[test]' 27 | - name: Run tests 28 | run: | 29 | pytest 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | .vscode 3 | .venv 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | venv 8 | .eggs 9 | .pytest_cache 10 | *.egg-info 11 | .DS_Store 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # datasette-graphql 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/datasette-graphql.svg)](https://pypi.org/project/datasette-graphql/) 4 | [![Changelog](https://img.shields.io/github/v/release/simonw/datasette-graphql?include_prereleases&label=changelog)](https://github.com/simonw/datasette-graphql/releases) 5 | [![Tests](https://github.com/simonw/datasette-graphql/workflows/Test/badge.svg)](https://github.com/simonw/datasette-graphql/actions?query=workflow%3ATest) 6 | [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/datasette-graphql/blob/main/LICENSE) 7 | 8 | **Datasette plugin providing an automatic GraphQL API for your SQLite databases** 9 | 10 | Read more about this project: [GraphQL in Datasette with the new datasette-graphql plugin](https://simonwillison.net/2020/Aug/7/datasette-graphql/) 11 | 12 | Try out a live demo at [datasette-graphql-demo.datasette.io/graphql](https://datasette-graphql-demo.datasette.io/graphql?query=%7B%0A%20%20repos(first%3A10%2C%20search%3A%20%22sql%22%2C%20sort_desc%3A%20created_at)%20%7B%0A%20%20%20%20totalCount%0A%20%20%20%20pageInfo%20%7B%0A%20%20%20%20%20%20endCursor%0A%20%20%20%20%20%20hasNextPage%0A%20%20%20%20%7D%0A%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20full_name%0A%20%20%20%20%20%20description_%0A%20%20%20%20%09stargazers_count%0A%20%20%20%20%20%20created_at%0A%20%20%20%20%20%20owner%20%7B%0A%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20html_url%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A) 13 | 14 | 15 | 16 | - [Installation](#installation) 17 | - [Configuration](#configuration) 18 | - [Usage](#usage) 19 | * [Querying for tables and columns](#querying-for-tables-and-columns) 20 | * [Fetching a single record](#fetching-a-single-record) 21 | * [Accessing nested objects](#accessing-nested-objects) 22 | * [Accessing related objects](#accessing-related-objects) 23 | * [Filtering tables](#filtering-tables) 24 | * [Sorting](#sorting) 25 | * [Pagination](#pagination) 26 | * [Search](#search) 27 | * [Columns containing JSON strings](#columns-containing-json-strings) 28 | * [Auto camelCase](#auto-camelcase) 29 | * [CORS](#cors) 30 | * [Execution limits](#execution-limits) 31 | - [The graphql() template function](#the-graphql-template-function) 32 | - [Adding custom fields with plugins](#adding-custom-fields-with-plugins) 33 | - [Development](#development) 34 | 35 | 36 | 37 | ![Animated demo showing autocomplete while typing a GraphQL query into the GraphiQL interface](https://static.simonwillison.net/static/2020/graphiql.gif) 38 | 39 | ## Installation 40 | 41 | Install this plugin in the same environment as Datasette. 42 | 43 | $ datasette install datasette-graphql 44 | 45 | ## Configuration 46 | 47 | By default this plugin adds the GraphQL API at `/graphql`. You can configure a different path using the `path` plugin setting, for example by adding this to `metadata.json`: 48 | ```json 49 | { 50 | "plugins": { 51 | "datasette-graphql": { 52 | "path": "/-/graphql" 53 | } 54 | } 55 | } 56 | ``` 57 | This will set the GraphQL API to live at `/-/graphql` instead. 58 | 59 | ## Usage 60 | 61 | This plugin sets up `/graphql` as a GraphQL endpoint for the first attached database. 62 | 63 | If you have multiple attached databases each will get its own endpoint at `/graphql/name_of_database`. 64 | 65 | The automatically generated GraphQL schema is available at `/graphql/name_of_database.graphql` - here's [an example](https://datasette-graphql-demo.datasette.io/graphql/github.graphql). 66 | 67 | ### Querying for tables and columns 68 | 69 | Individual tables (and SQL views) can be queried like this: 70 | 71 | ```graphql 72 | { 73 | repos { 74 | nodes { 75 | id 76 | full_name 77 | description_ 78 | } 79 | } 80 | } 81 | ``` 82 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql?query=%0A%7B%0A%20%20repos%20%7B%0A%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20id%0A%20%20%20%20%20%20full_name%0A%20%20%20%20%20%20description_%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A) 83 | 84 | In this example query the underlying database table is called `repos` and its columns include `id`, `full_name` and `description`. Since `description` is a reserved word the query needs to ask for `description_` instead. 85 | 86 | ### Fetching a single record 87 | 88 | If you only want to fetch a single record - for example if you want to fetch a row by its primary key - you can use the `tablename_row` field: 89 | 90 | ```graphql 91 | { 92 | repos_row(id: 107914493) { 93 | id 94 | full_name 95 | description_ 96 | } 97 | } 98 | ``` 99 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql?query=%0A%7B%0A%20%20repos_row%28id%3A%20107914493%29%20%7B%0A%20%20%20%20id%0A%20%20%20%20full_name%0A%20%20%20%20description_%0A%20%20%7D%0A%7D%0A) 100 | 101 | The `tablename_row` field accepts the primary key column (or columns) as arguments. It also supports the same `filter:`, `search:`, `sort:` and `sort_desc:` arguments as the `tablename` field, described below. 102 | 103 | ### Accessing nested objects 104 | 105 | If a column is a foreign key to another table, you can request columns from the table pointed to by that foreign key using a nested query like this: 106 | 107 | ```graphql 108 | { 109 | repos { 110 | nodes { 111 | id 112 | full_name 113 | owner { 114 | id 115 | login 116 | } 117 | } 118 | } 119 | } 120 | ``` 121 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql?query=%0A%7B%0A%20%20repos%20%7B%0A%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20id%0A%20%20%20%20%20%20full_name%0A%20%20%20%20%20%20owner%20%7B%0A%20%20%20%20%20%20%20%20id%0A%20%20%20%20%20%20%20%20login%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A) 122 | 123 | ### Accessing related objects 124 | 125 | If another table has a foreign key back to the table you are accessing, you can fetch rows from that related table. 126 | 127 | Consider a `users` table which is related to `repos` - a repo has a foreign key back to the user that owns the repository. The `users` object type will have a `repos_by_owner_list` field which can be used to access those related repos: 128 | 129 | ```graphql 130 | { 131 | users(first: 1, search: "simonw") { 132 | nodes { 133 | name 134 | repos_by_owner_list(first: 5) { 135 | totalCount 136 | nodes { 137 | full_name 138 | } 139 | } 140 | } 141 | } 142 | } 143 | ``` 144 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql?query=%0A%7B%0A%20%20users%28first%3A%201%2C%20search%3A%20%22simonw%22%29%20%7B%0A%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20name%0A%20%20%20%20%20%20repos_by_owner_list%28first%3A%205%29%20%7B%0A%20%20%20%20%20%20%20%20totalCount%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20full_name%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A) 145 | 146 | 147 | ### Filtering tables 148 | 149 | You can filter the rows returned for a specific table using the `filter:` argument. This accepts a filter object mapping columns to operations. For example, to return just repositories with the Apache 2 license and more than 10 stars: 150 | 151 | ```graphql 152 | { 153 | repos(filter: {license: {eq: "apache-2.0"}, stargazers_count: {gt: 10}}) { 154 | nodes { 155 | full_name 156 | stargazers_count 157 | license { 158 | key 159 | } 160 | } 161 | } 162 | } 163 | ``` 164 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql?query=%0A%7B%0A%20%20repos%28filter%3A%20%7Blicense%3A%20%7Beq%3A%20%22apache-2.0%22%7D%2C%20stargazers_count%3A%20%7Bgt%3A%2010%7D%7D%29%20%7B%0A%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20full_name%0A%20%20%20%20%20%20stargazers_count%0A%20%20%20%20%20%20license%20%7B%0A%20%20%20%20%20%20%20%20key%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A) 165 | 166 | See [table filters examples](https://github.com/simonw/datasette-graphql/blob/main/examples/filters.md) for more operations, and [column filter arguments](https://docs.datasette.io/en/stable/json_api.html#column-filter-arguments) in the Datasette documentation for details of how those operations work. 167 | 168 | These same filters can be used on nested relationships, like so: 169 | 170 | ```graphql 171 | { 172 | users_row(id: 9599) { 173 | name 174 | repos_by_owner_list(filter: {name: {startswith: "datasette-"}}) { 175 | totalCount 176 | nodes { 177 | full_name 178 | } 179 | } 180 | } 181 | } 182 | ``` 183 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql?query=%0A%7B%0A%20%20users_row%28id%3A%209599%29%20%7B%0A%20%20%20%20name%0A%20%20%20%20repos_by_owner_list%28filter%3A%20%7Bname%3A%20%7Bstartswith%3A%20%22datasette-%22%7D%7D%29%20%7B%0A%20%20%20%20%20%20totalCount%0A%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20full_name%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A) 184 | 185 | 186 | The `where:` argument can be used as an alternative to `filter:` when the thing you are expressing is too complex to be modeled using a filter expression. It accepts a string fragment of SQL that will be included in the `WHERE` clause of the SQL query. 187 | 188 | ```graphql 189 | { 190 | repos(where: "name='sqlite-utils' or name like 'datasette-%'") { 191 | totalCount 192 | nodes { 193 | full_name 194 | } 195 | } 196 | } 197 | ``` 198 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql?query=%0A%7B%0A%20%20repos%28where%3A%20%22name%3D%27sqlite-utils%27%20or%20name%20like%20%27datasette-%25%27%22%29%20%7B%0A%20%20%20%20totalCount%0A%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20full_name%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A) 199 | 200 | ### Sorting 201 | 202 | You can set a sort order for results from a table using the `sort:` or `sort_desc:` arguments. The value for this argument should be the name of the column you wish to sort (or sort-descending) by. 203 | 204 | ```graphql 205 | { 206 | repos(sort_desc: stargazers_count) { 207 | nodes { 208 | full_name 209 | stargazers_count 210 | } 211 | } 212 | } 213 | ``` 214 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql?query=%0A%7B%0A%20%20repos%28sort_desc%3A%20stargazers_count%29%20%7B%0A%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20full_name%0A%20%20%20%20%20%20stargazers_count%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A) 215 | 216 | ### Pagination 217 | 218 | By default the first 10 rows will be returned. You can control this using the `first:` argument. 219 | 220 | ```graphql 221 | { 222 | repos(first: 20) { 223 | totalCount 224 | pageInfo { 225 | hasNextPage 226 | endCursor 227 | } 228 | nodes { 229 | full_name 230 | stargazers_count 231 | license { 232 | key 233 | } 234 | } 235 | } 236 | } 237 | ``` 238 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql?query=%0A%7B%0A%20%20repos%28first%3A%2020%29%20%7B%0A%20%20%20%20totalCount%0A%20%20%20%20pageInfo%20%7B%0A%20%20%20%20%20%20hasNextPage%0A%20%20%20%20%20%20endCursor%0A%20%20%20%20%7D%0A%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20full_name%0A%20%20%20%20%20%20stargazers_count%0A%20%20%20%20%20%20license%20%7B%0A%20%20%20%20%20%20%20%20key%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A) 239 | 240 | The `totalCount` field returns the total number of records that match the query. 241 | 242 | Requesting the `pageInfo.endCursor` field provides you with the value you need to request the next page. You can pass this to the `after:` argument to request the next page. 243 | 244 | ```graphql 245 | { 246 | repos(first: 20, after: "134874019") { 247 | totalCount 248 | pageInfo { 249 | hasNextPage 250 | endCursor 251 | } 252 | nodes { 253 | full_name 254 | stargazers_count 255 | license { 256 | key 257 | } 258 | } 259 | } 260 | } 261 | ``` 262 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql?query=%0A%7B%0A%20%20repos%28first%3A%2020%2C%20after%3A%20%22134874019%22%29%20%7B%0A%20%20%20%20totalCount%0A%20%20%20%20pageInfo%20%7B%0A%20%20%20%20%20%20hasNextPage%0A%20%20%20%20%20%20endCursor%0A%20%20%20%20%7D%0A%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20full_name%0A%20%20%20%20%20%20stargazers_count%0A%20%20%20%20%20%20license%20%7B%0A%20%20%20%20%20%20%20%20key%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A) 263 | 264 | The `hasNextPage` field tells you if there are any more records. 265 | 266 | ### Search 267 | 268 | If a table has been configured to use SQLite full-text search you can execute searches against it using the `search:` argument: 269 | 270 | ```graphql 271 | { 272 | repos(search: "datasette") { 273 | totalCount 274 | pageInfo { 275 | hasNextPage 276 | endCursor 277 | } 278 | nodes { 279 | full_name 280 | description_ 281 | } 282 | } 283 | } 284 | ``` 285 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql?query=%0A%7B%0A%20%20repos%28search%3A%20%22datasette%22%29%20%7B%0A%20%20%20%20totalCount%0A%20%20%20%20pageInfo%20%7B%0A%20%20%20%20%20%20hasNextPage%0A%20%20%20%20%20%20endCursor%0A%20%20%20%20%7D%0A%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20full_name%0A%20%20%20%20%20%20description_%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A) 286 | 287 | The [sqlite-utils](https://sqlite-utils.datasette.io/) Python library and CLI tool can be used to add full-text search to an existing database table. 288 | 289 | ### Columns containing JSON strings 290 | 291 | If your table has a column that contains data encoded as JSON, `datasette-graphql` will make that column available as an encoded JSON string. Clients calling your API will need to parse the string as JSON in order to access the data. 292 | 293 | You can return the data as a nested structure by configuring that column to be treated as a JSON column. The [plugin configuration](https://docs.datasette.io/en/stable/plugins.html#plugin-configuration) for that in `metadata.json` looks like this: 294 | 295 | ```json 296 | { 297 | "databases": { 298 | "test": { 299 | "tables": { 300 | "repos": { 301 | "plugins": { 302 | "datasette-graphql": { 303 | "json_columns": [ 304 | "tags" 305 | ] 306 | } 307 | } 308 | } 309 | } 310 | } 311 | } 312 | } 313 | ``` 314 | 315 | ### Auto camelCase 316 | 317 | The names of your columns and tables default to being matched by their representations in GraphQL. 318 | 319 | If you have tables with `names_like_this` you may want to work with them in GraphQL using `namesLikeThis`, for consistency with GraphQL and JavaScript conventions. 320 | 321 | You can turn on automatic camelCase using the `"auto_camelcase"` plugin configuration setting in `metadata.json`, like this: 322 | 323 | ```json 324 | { 325 | "plugins": { 326 | "datasette-graphql": { 327 | "auto_camelcase": true 328 | } 329 | } 330 | } 331 | ``` 332 | 333 | ### CORS 334 | 335 | This plugin obeys the `--cors` option passed to the `datasette` command-line tool. If you pass `--cors` it adds the following CORS HTTP headers to allow JavaScript running on other domains to access the GraphQL API: 336 | 337 | access-control-allow-headers: content-type 338 | access-control-allow-method: POST 339 | access-control-allow-origin: * 340 | 341 | ### Execution limits 342 | 343 | The plugin implements two limits by default: 344 | 345 | - The total time spent executing all of the underlying SQL queries that make up the GraphQL execution must not exceed 1000ms (one second) 346 | - The total number of SQL table queries executed as a result of nested GraphQL fields must not exceed 100 347 | 348 | These limits can be customized using the `num_queries_limit` and `time_limit_ms` plugin configuration settings, for example in `metadata.json`: 349 | 350 | ```json 351 | { 352 | "plugins": { 353 | "datasette-graphql": { 354 | "num_queries_limit": 200, 355 | "time_limit_ms": 5000 356 | } 357 | } 358 | } 359 | ``` 360 | Setting these to `0` will disable the limit checks entirely. 361 | 362 | ## The graphql() template function 363 | 364 | The plugin also makes a Jinja template function available called `graphql()`. You can use that function in your Datasette [custom templates](https://docs.datasette.io/en/stable/custom_templates.html#custom-templates) like so: 365 | 366 | ```html+jinja 367 | {% set users = graphql(""" 368 | { 369 | users { 370 | nodes { 371 | name 372 | points 373 | score 374 | } 375 | } 376 | } 377 | """)["users"] %} 378 | {% for user in users.nodes %} 379 |

{{ user.name }} - points: {{ user.points }}, score = {{ user.score }}

380 | {% endfor %} 381 | ``` 382 | 383 | The function executes a GraphQL query against the generated schema and returns the results. You can assign those results to a variable in your template and then loop through and display them. 384 | 385 | By default the query will be run against the first attached database. You can use the optional second argument to the function to specify a different database - for example, to run against an attached `github.db` database you would do this: 386 | 387 | ```html+jinja 388 | {% set user = graphql(""" 389 | { 390 | users_row(id:9599) { 391 | name 392 | login 393 | avatar_url 394 | } 395 | } 396 | """, "github")["users_row"] %} 397 | 398 |

Hello, {{ user.name }}

399 | ``` 400 | 401 | You can use [GraphQL variables](https://graphql.org/learn/queries/#variables) in these template calls by passing them to the `variables=` argument: 402 | 403 | ```html+jinja 404 | {% set user = graphql(""" 405 | query ($id: Int) { 406 | users_row(id: $id) { 407 | name 408 | login 409 | avatar_url 410 | } 411 | } 412 | """, database="github", variables={"id": 9599})["users_row"] %} 413 | 414 |

Hello, {{ user.name }}

415 | ``` 416 | ## Adding custom fields with plugins 417 | 418 | `datasette-graphql` adds a new [plugin hook](https://docs.datasette.io/en/stable/writing_plugins.html) to Datasette which can be used to add custom fields to your GraphQL schema. 419 | 420 | The plugin hook looks like this: 421 | 422 | ```python 423 | @hookimpl 424 | def graphql_extra_fields(datasette, database): 425 | "A list of (name, field_type) tuples to include in the GraphQL schema" 426 | ``` 427 | 428 | You can use this hook to return a list of tuples describing additional fields that should be exposed in your schema. Each tuple should consist of a string naming the new field, plus a [Graphene Field object](https://docs.graphene-python.org/en/latest/types/objecttypes/) that specifies the schema and provides a `resolver` function. 429 | 430 | This example implementation uses `pkg_resources` to return a list of currently installed Python packages: 431 | 432 | ```python 433 | import graphene 434 | from datasette import hookimpl 435 | import pkg_resources 436 | 437 | 438 | @hookimpl 439 | def graphql_extra_fields(): 440 | class Package(graphene.ObjectType): 441 | "An installed package" 442 | name = graphene.String() 443 | version = graphene.String() 444 | 445 | def resolve_packages(root, info): 446 | return [ 447 | {"name": d.project_name, "version": d.version} 448 | for d in pkg_resources.working_set 449 | ] 450 | 451 | return [ 452 | ( 453 | "packages", 454 | graphene.Field( 455 | graphene.List(Package), 456 | description="List of installed packages", 457 | resolver=resolve_packages, 458 | ), 459 | ), 460 | ] 461 | ``` 462 | 463 | With this plugin installed, the following GraphQL query can be used to retrieve a list of installed packages: 464 | 465 | ```graphql 466 | { 467 | packages { 468 | name 469 | version 470 | } 471 | } 472 | ``` 473 | 474 | ## Development 475 | 476 | To set up this plugin locally, first checkout the code. Then create a new virtual environment: 477 | 478 | cd datasette-graphql 479 | python3 -mvenv venv 480 | source venv/bin/activate 481 | 482 | Or if you are using `pipenv`: 483 | 484 | pipenv shell 485 | 486 | Now install the dependencies and tests: 487 | 488 | pip install -e '.[test]' 489 | 490 | To run the tests: 491 | 492 | pytest 493 | -------------------------------------------------------------------------------- /datasette_graphql/__init__.py: -------------------------------------------------------------------------------- 1 | from click import ClickException 2 | from datasette import hookimpl 3 | from datasette.utils.asgi import Response, NotFound, Forbidden 4 | from datasette.plugins import pm 5 | from graphql import graphql, print_schema 6 | import json 7 | from .utils import schema_for_database_via_cache 8 | from . import hookspecs 9 | import pathlib 10 | import time 11 | import urllib 12 | 13 | pm.add_hookspecs(hookspecs) 14 | 15 | DEFAULT_TIME_LIMIT_MS = 1000 16 | DEFAULT_NUM_QUERIES_LIMIT = 100 17 | 18 | 19 | async def post_body(request): 20 | body = b"" 21 | more_body = True 22 | while more_body: 23 | message = await request.receive() 24 | assert message["type"] == "http.request", message 25 | body += message.get("body", b"") 26 | more_body = message.get("more_body", False) 27 | 28 | return body 29 | 30 | 31 | async def view_graphql_schema(request, datasette): 32 | database = request.url_vars.get("database") 33 | try: 34 | db = datasette.get_database(database) 35 | except KeyError: 36 | raise NotFound("Database does not exist") 37 | await check_permissions(request, datasette, db.name) 38 | schema = await schema_for_database_via_cache(datasette, database=database) 39 | return Response.text(print_schema(schema.schema.graphql_schema)) 40 | 41 | 42 | CORS_HEADERS = { 43 | "Access-Control-Allow-Headers": "content-type", 44 | "Access-Control-Allow-Method": "POST", 45 | "Access-Control-Allow-Origin": "*", 46 | "Vary": "accept", 47 | } 48 | 49 | 50 | async def view_graphql(request, datasette): 51 | if request.method == "OPTIONS": 52 | return Response.text("ok", headers=CORS_HEADERS if datasette.cors else {}) 53 | 54 | body = await post_body(request) 55 | database = request.url_vars.get("database") 56 | 57 | try: 58 | db = datasette.get_database(database) 59 | except KeyError: 60 | raise NotFound("Database does not exist") 61 | 62 | await check_permissions(request, datasette, db.name) 63 | 64 | if not body and "text/html" in request.headers.get("accept", ""): 65 | static = pathlib.Path(__file__).parent / "static" 66 | js_filenames = [p.name for p in static.glob("*.js")] 67 | # These need to be sorted so react loads first 68 | order = "react", "react-dom", "graphiql" 69 | js_filenames.sort(key=lambda filename: order.index(filename.split(".")[0])) 70 | return Response.html( 71 | await datasette.render_template( 72 | "graphiql.html", 73 | { 74 | "database": database, 75 | "graphiql_css": [p.name for p in static.glob("*.css")], 76 | "graphiql_js": js_filenames, 77 | "graphql_path": _graphql_path(datasette), 78 | }, 79 | request=request, 80 | ), 81 | headers=CORS_HEADERS if datasette.cors else {}, 82 | ) 83 | 84 | schema = (await schema_for_database_via_cache(datasette, database=database)).schema 85 | 86 | if request.args.get("schema"): 87 | return Response.text(print_schema(schema.graphql_schema)) 88 | 89 | incoming = {} 90 | if body: 91 | incoming = json.loads(body) 92 | query = incoming.get("query") 93 | variables = incoming.get("variables") 94 | operation_name = incoming.get("operationName") 95 | else: 96 | query = request.args.get("query") 97 | variables = request.args.get("variables", "") 98 | if variables: 99 | variables = json.loads(variables) 100 | operation_name = request.args.get("operationName") 101 | 102 | if not query: 103 | return Response.json( 104 | {"error": "Missing query"}, 105 | status=400, 106 | headers=CORS_HEADERS if datasette.cors else {}, 107 | ) 108 | 109 | config = datasette.plugin_config("datasette-graphql") or {} 110 | context = { 111 | "time_started": time.monotonic(), 112 | "time_limit_ms": config.get("time_limit_ms") or DEFAULT_TIME_LIMIT_MS, 113 | "num_queries_executed": 0, 114 | "num_queries_limit": config.get("num_queries_limit") 115 | or DEFAULT_NUM_QUERIES_LIMIT, 116 | } 117 | 118 | result = await schema.execute_async( 119 | query, 120 | operation_name=operation_name, 121 | variable_values=variables or {}, 122 | context_value=context, 123 | ) 124 | response = {"data": result.data} 125 | if result.errors: 126 | response["errors"] = [error.formatted for error in result.errors] 127 | 128 | return Response.json( 129 | response, 130 | status=200 if not result.errors else 500, 131 | headers=CORS_HEADERS if datasette.cors else {}, 132 | ) 133 | 134 | 135 | async def check_permissions(request, datasette, database): 136 | # First check database permission 137 | ok = await datasette.permission_allowed( 138 | request.actor, 139 | "view-database", 140 | resource=database, 141 | default=None, 142 | ) 143 | if ok is not None: 144 | if ok: 145 | return 146 | else: 147 | raise Forbidden("view-database") 148 | 149 | # Fall back to checking instance permission 150 | ok2 = await datasette.permission_allowed( 151 | request.actor, 152 | "view-instance", 153 | default=None, 154 | ) 155 | if ok2 is False: 156 | raise Forbidden("view-instance") 157 | 158 | 159 | @hookimpl 160 | def menu_links(datasette, actor): 161 | graphql_path = _graphql_path(datasette) 162 | return [ 163 | {"href": datasette.urls.path(graphql_path), "label": "GraphQL API"}, 164 | ] 165 | 166 | 167 | def _graphql_path(datasette): 168 | config = datasette.plugin_config("datasette-graphql") or {} 169 | graphql_path = None 170 | if "path" not in config: 171 | graphql_path = "/graphql" 172 | else: 173 | graphql_path = config["path"] 174 | assert graphql_path.startswith("/") and not graphql_path.endswith( 175 | "/" 176 | ), '"path" must start with / and must not end with /' 177 | return graphql_path 178 | 179 | 180 | @hookimpl 181 | def register_routes(datasette): 182 | graphql_path = _graphql_path(datasette) 183 | return [ 184 | ( 185 | r"^{}/(?P[^/]+)\.graphql$".format(graphql_path), 186 | view_graphql_schema, 187 | ), 188 | (r"^{}/(?P[^/]+)$".format(graphql_path), view_graphql), 189 | (r"^{}$".format(graphql_path), view_graphql), 190 | ] 191 | 192 | 193 | @hookimpl 194 | def extra_template_vars(datasette): 195 | async def graphql_template_tag(query, database=None, variables=None): 196 | schema = ( 197 | await schema_for_database_via_cache(datasette, database=database) 198 | ).schema 199 | result = await schema.execute_async( 200 | query, 201 | variable_values=variables or {}, 202 | ) 203 | if result.errors: 204 | raise Exception(result.errors) 205 | else: 206 | return result.data 207 | 208 | return { 209 | "graphql": graphql_template_tag, 210 | } 211 | 212 | 213 | @hookimpl 214 | def startup(datasette): 215 | # Validate configuration 216 | config = datasette.plugin_config("datasette-graphql") or {} 217 | if "databases" in config: 218 | for database_name in config["databases"].keys(): 219 | try: 220 | datasette.get_database(database_name) 221 | except KeyError: 222 | raise ClickException( 223 | "datasette-graphql config error: '{}' is not a connected database".format( 224 | database_name 225 | ) 226 | ) 227 | 228 | 229 | @hookimpl 230 | def table_actions(datasette, actor, database, table): 231 | async def inner(): 232 | graphql_path = datasette.urls.path( 233 | "{}/{}".format(_graphql_path(datasette), database) 234 | ) 235 | db_schema = await schema_for_database_via_cache(datasette, database=database) 236 | try: 237 | example_query = await db_schema.table_classes[table].example_query() 238 | except KeyError: 239 | # https://github.com/simonw/datasette-graphql/issues/90 240 | return [] 241 | return [ 242 | { 243 | "href": "{}?query={}".format( 244 | graphql_path, urllib.parse.quote(example_query) 245 | ), 246 | "label": "GraphQL API for {}".format(table), 247 | } 248 | ] 249 | 250 | return inner 251 | 252 | 253 | @hookimpl 254 | def database_actions(datasette, actor, database): 255 | graphql_path = _graphql_path(datasette) 256 | if len(datasette.databases) > 1: 257 | return [ 258 | { 259 | "href": datasette.urls.path("{}/{}".format(graphql_path, database)), 260 | "label": "GraphQL API for {}".format(database), 261 | } 262 | ] 263 | else: 264 | return [ 265 | { 266 | "href": datasette.urls.path(graphql_path), 267 | "label": "GraphQL API", 268 | } 269 | ] 270 | -------------------------------------------------------------------------------- /datasette_graphql/hookspecs.py: -------------------------------------------------------------------------------- 1 | from pluggy import HookspecMarker 2 | 3 | hookspec = HookspecMarker("datasette") 4 | 5 | 6 | @hookspec 7 | def graphql_extra_fields(datasette, database): 8 | "A list of (name, field_type) tuples to include in the GraphQL schema" 9 | -------------------------------------------------------------------------------- /datasette_graphql/static/LICENSES.md: -------------------------------------------------------------------------------- 1 | # Licenses 2 | 3 | The files bundled in this directory are covered by the following licenses: 4 | 5 | - `fetch.min.js` is covered by this MIT license: https://github.com/github/fetch/blob/master/LICENSE 6 | - `graphiql.css` and `graphiql.min.js` are covered by this MIT license: https://github.com/graphql/graphiql/blob/main/LICENSE 7 | - `react-dom.production.min.js` and `react.production.min.js` are covered by this MIT license: https://github.com/facebook/react/blob/master/LICENSE 8 | -------------------------------------------------------------------------------- /datasette_graphql/static/fetch_latest.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | 3 | urls = ( 4 | "https://unpkg.com/react/umd/react.production.min.js", 5 | "https://unpkg.com/react-dom/umd/react-dom.production.min.js", 6 | "https://unpkg.com/graphiql/graphiql.min.css", 7 | "https://unpkg.com/graphiql/graphiql.min.js", 8 | ) 9 | 10 | 11 | def fetch(url): 12 | final_url = httpx.get(url).next_request.url 13 | content = httpx.get(final_url).text 14 | version = str(final_url).split("@")[1].split("/")[0] 15 | filename = str(final_url).split("/")[-1] 16 | # Insert version into filename 17 | bits = filename.split(".min.") 18 | version_filename = f".{version}.min.".join(bits) 19 | open(version_filename, "w").write(content) 20 | print(version_filename) 21 | 22 | 23 | if __name__ == "__main__": 24 | for url in urls: 25 | fetch(url) 26 | -------------------------------------------------------------------------------- /datasette_graphql/static/graphiql.1.5.1.min.css: -------------------------------------------------------------------------------- 1 | .graphiql-container,.graphiql-container button,.graphiql-container input{color:#141823;font-family:system,-apple-system,San Francisco,\.SFNSDisplay-Regular,Segoe UI,Segoe,Segoe WP,Helvetica Neue,helvetica,Lucida Grande,arial,sans-serif;font-size:14px}.graphiql-container{display:flex;flex-direction:row;height:100%;margin:0;overflow:hidden;width:100%}.graphiql-container .editorWrap{display:flex;flex-direction:column;flex:1;overflow-x:hidden}.graphiql-container .title{font-size:18px}.graphiql-container .title em{font-family:georgia;font-size:19px}.graphiql-container .topBarWrap{display:flex;flex-direction:row}.graphiql-container .topBar{align-items:center;background:linear-gradient(#f7f7f7,#e2e2e2);border-bottom:1px solid #d0d0d0;cursor:default;display:flex;flex-direction:row;flex:1;height:34px;overflow-y:visible;padding:7px 14px 6px;user-select:none}.graphiql-container .toolbar{overflow-x:visible;display:flex}.graphiql-container .docExplorerShow,.graphiql-container .historyShow{background:linear-gradient(#f7f7f7,#e2e2e2);border-radius:0;border-bottom:1px solid #d0d0d0;border-right:none;border-top:none;color:#3b5998;cursor:pointer;font-size:14px;margin:0;padding:2px 20px 0 18px}.graphiql-container .docExplorerShow{border-left:1px solid rgba(0,0,0,.2)}.graphiql-container .historyShow{border-right:1px solid rgba(0,0,0,.2);border-left:0}.graphiql-container .docExplorerShow:before{border-left:2px solid #3b5998;border-top:2px solid #3b5998;content:"";display:inline-block;height:9px;margin:0 3px -1px 0;position:relative;transform:rotate(-45deg);width:9px}.graphiql-container .editorBar{display:flex;flex-direction:row;flex:1}.graphiql-container .queryWrap,.graphiql-container .resultWrap{display:flex;flex-direction:column;flex:1}.graphiql-container .resultWrap{border-left:1px solid #e0e0e0;flex-basis:1em;position:relative}.graphiql-container .docExplorerWrap,.graphiql-container .historyPaneWrap{background:#fff;box-shadow:0 0 8px rgba(0,0,0,.15);position:relative;z-index:3}.graphiql-container .historyPaneWrap{min-width:230px;z-index:5}.graphiql-container .docExplorerResizer{cursor:col-resize;height:100%;left:-5px;position:absolute;top:0;width:10px;z-index:10}.graphiql-container .docExplorerHide{cursor:pointer;font-size:18px;margin:-7px -8px -6px 0;padding:18px 16px 15px 12px;background:0;border:0;line-height:14px}.graphiql-container div .query-editor{flex:1;position:relative}.graphiql-container .secondary-editor{display:flex;flex-direction:column;height:30px;position:relative}.graphiql-container .secondary-editor-title{background:#eee;border-bottom:1px solid #d6d6d6;border-top:1px solid #e0e0e0;color:#777;font-variant:small-caps;font-weight:700;letter-spacing:1px;line-height:14px;padding:6px 0 8px 43px;text-transform:lowercase;user-select:none}.graphiql-container .codemirrorWrap,.graphiql-container .result-window{flex:1;height:100%;position:relative}.graphiql-container .footer{background:#f6f7f8;border-left:1px solid #e0e0e0;border-top:1px solid #e0e0e0;margin-left:12px;position:relative}.graphiql-container .footer:before{background:#eee;bottom:0;content:" ";left:-13px;position:absolute;top:-1px;width:12px}.result-window .CodeMirror.cm-s-graphiql{background:#f6f7f8}.graphiql-container .result-window .CodeMirror-gutters{background-color:#eee;border-color:#e0e0e0;cursor:col-resize}.graphiql-container .result-window .CodeMirror-foldgutter,.graphiql-container .result-window .CodeMirror-foldgutter-folded:after,.graphiql-container .result-window .CodeMirror-foldgutter-open:after{padding-left:3px}.graphiql-container .toolbar-button{background:#fdfdfd;background:linear-gradient(#f9f9f9,#ececec);border:0;border-radius:3px;box-shadow:inset 0 0 0 1px rgba(0,0,0,.2),0 1px 0 hsla(0,0%,100%,.7),inset 0 1px #fff;color:#555;cursor:pointer;display:inline-block;margin:0 5px;padding:3px 11px 5px;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;max-width:150px}.graphiql-container .toolbar-button:active{background:linear-gradient(#ececec,#d5d5d5);box-shadow:0 1px 0 hsla(0,0%,100%,.7),inset 0 0 0 1px rgba(0,0,0,.1),inset 0 1px 1px 1px rgba(0,0,0,.12),inset 0 0 5px rgba(0,0,0,.1)}.graphiql-container .toolbar-button.error{background:linear-gradient(#fdf3f3,#e6d6d7);color:#b00}.graphiql-container .toolbar-button-group{margin:0 5px;white-space:nowrap}.graphiql-container .toolbar-button-group>*{margin:0}.graphiql-container .toolbar-button-group>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.graphiql-container .toolbar-button-group>:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0;margin-left:-1px}.graphiql-container .execute-button-wrap{height:34px;margin:0 14px 0 28px;position:relative}.graphiql-container .execute-button{background:linear-gradient(#fdfdfd,#d2d3d6);border-radius:17px;border:1px solid rgba(0,0,0,.25);box-shadow:0 1px 0 #fff;cursor:pointer;fill:#444;height:34px;margin:0;padding:0;width:34px}.graphiql-container .execute-button svg{pointer-events:none}.graphiql-container .execute-button:active{background:linear-gradient(#e6e6e6,#c3c3c3);box-shadow:0 1px 0 #fff,inset 0 0 2px rgba(0,0,0,.2),inset 0 0 6px rgba(0,0,0,.1)}.graphiql-container .toolbar-menu,.graphiql-container .toolbar-select{position:relative}.graphiql-container .execute-options,.graphiql-container .toolbar-menu-items,.graphiql-container .toolbar-select-options{background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.1),0 2px 4px rgba(0,0,0,.25);margin:0;padding:6px 0;position:absolute;z-index:100}.graphiql-container .execute-options{min-width:100px;top:37px;left:-1px}.graphiql-container .toolbar-menu-items{left:1px;margin-top:-1px;min-width:110%;top:100%;visibility:hidden}.graphiql-container .toolbar-menu-items.open{visibility:visible}.graphiql-container .toolbar-select-options{left:0;min-width:100%;top:-5px;visibility:hidden}.graphiql-container .toolbar-select-options.open{visibility:visible}.graphiql-container .execute-options>li,.graphiql-container .toolbar-menu-items>li,.graphiql-container .toolbar-select-options>li{cursor:pointer;display:block;margin:none;max-width:300px;overflow:hidden;padding:2px 20px 4px 11px;white-space:nowrap}.graphiql-container .execute-options>li.selected,.graphiql-container .history-contents>li:active,.graphiql-container .history-contents>li:hover,.graphiql-container .toolbar-menu-items>li.hover,.graphiql-container .toolbar-menu-items>li:active,.graphiql-container .toolbar-menu-items>li:hover,.graphiql-container .toolbar-select-options>li.hover,.graphiql-container .toolbar-select-options>li:active,.graphiql-container .toolbar-select-options>li:hover{background:#e10098;color:#fff}.graphiql-container .toolbar-select-options>li>svg{display:inline;fill:#666;margin:0 -6px 0 6px;pointer-events:none;vertical-align:middle}.graphiql-container .toolbar-select-options>li.hover>svg,.graphiql-container .toolbar-select-options>li:active>svg,.graphiql-container .toolbar-select-options>li:hover>svg{fill:#fff}.graphiql-container .CodeMirror-scroll{overflow-scrolling:touch}.graphiql-container .CodeMirror{color:#141823;font-family:Consolas,Inconsolata,Droid Sans Mono,Monaco,monospace;font-size:13px;height:100%;left:0;position:absolute;top:0;width:100%}.graphiql-container .CodeMirror-lines{padding:20px 0}.CodeMirror-hint-information .content{box-orient:vertical;color:#141823;display:flex;font-family:system,-apple-system,San Francisco,\.SFNSDisplay-Regular,Segoe UI,Segoe,Segoe WP,Helvetica Neue,helvetica,Lucida Grande,arial,sans-serif;font-size:13px;line-clamp:3;line-height:16px;max-height:48px;overflow:hidden;text-overflow:-o-ellipsis-lastline}.CodeMirror-hint-information .content p:first-child{margin-top:0}.CodeMirror-hint-information .content p:last-child{margin-bottom:0}.CodeMirror-hint-information .infoType{color:#ca9800;cursor:pointer;display:inline;margin-right:.5em}.autoInsertedLeaf.cm-property{animation-duration:6s;animation-name:insertionFade;border-bottom:2px solid hsla(0,0%,100%,0);border-radius:2px;margin:-2px -4px -1px;padding:2px 4px 1px}@keyframes insertionFade{0%,to{background:hsla(0,0%,100%,0);border-color:hsla(0,0%,100%,0)}15%,85%{background:#fbffc9;border-color:#f0f3c0}}div.CodeMirror-lint-tooltip{background-color:#fff;border-radius:2px;border:0;color:#141823;box-shadow:0 1px 3px rgba(0,0,0,.45);font-size:13px;line-height:16px;max-width:430px;opacity:0;padding:8px 10px;transition:opacity .15s;white-space:pre-wrap}div.CodeMirror-lint-tooltip>*{padding-left:23px}div.CodeMirror-lint-tooltip>*+*{margin-top:12px}.graphiql-container .CodeMirror-foldmarker{border-radius:4px;background:#08f;background:linear-gradient(#43a8ff,#0f83e8);box-shadow:0 1px 1px rgba(0,0,0,.2),inset 0 0 0 1px rgba(0,0,0,.1);color:#fff;font-family:arial;font-size:12px;line-height:0;margin:0 3px;padding:0 4px 1px;text-shadow:0 -1px rgba(0,0,0,.1)}.graphiql-container div.CodeMirror span.CodeMirror-matchingbracket{color:#555;text-decoration:underline}.graphiql-container div.CodeMirror span.CodeMirror-nonmatchingbracket{color:red}.cm-comment{color:#666}.cm-punctuation{color:#555}.cm-keyword{color:#b11a04}.cm-def{color:#d2054e}.cm-property{color:#1f61a0}.cm-qualifier{color:#1c92a9}.cm-attribute{color:#8b2bb9}.cm-number{color:#2882f9}.cm-string{color:#d64292}.cm-builtin{color:#d47509}.cm-string-2{color:#0b7fc7}.cm-variable{color:#397d13}.cm-meta{color:#b33086}.cm-atom{color:#ca9800} 2 | .CodeMirror{color:#000;font-family:monospace;height:300px}.CodeMirror-lines{padding:4px 0}.CodeMirror pre{padding:0 4px}.CodeMirror-gutter-filler,.CodeMirror-scrollbar-filler{background-color:#fff}.CodeMirror-gutters{border-right:1px solid #ddd;background-color:#f7f7f7;white-space:nowrap}.CodeMirror-linenumber{color:#666;min-width:20px;padding:0 3px 0 5px;text-align:right;white-space:nowrap}.CodeMirror-guttermarker{color:#000}.CodeMirror-guttermarker-subtle{color:#666}.CodeMirror .CodeMirror-cursor{border-left:1px solid #000}.CodeMirror div.CodeMirror-secondarycursor{border-left:1px solid silver}.CodeMirror.cm-fat-cursor div.CodeMirror-cursor{background:#7e7;border:0;width:auto}.CodeMirror.cm-fat-cursor div.CodeMirror-cursors{z-index:1}.cm-animate-fat-cursor{animation:blink 1.06s steps(1) infinite;border:0;width:auto}@keyframes blink{0%{background:#7e7}50%{background:none}to{background:#7e7}}.cm-tab{display:inline-block;text-decoration:inherit}.CodeMirror-ruler{border-left:1px solid #ccc;position:absolute}.cm-s-default .cm-keyword{color:#708}.cm-s-default .cm-atom{color:#219}.cm-s-default .cm-number{color:#164}.cm-s-default .cm-def{color:#00f}.cm-s-default .cm-variable-2{color:#05a}.cm-s-default .cm-variable-3{color:#085}.cm-s-default .cm-comment{color:#a50}.cm-s-default .cm-string{color:#a11}.cm-s-default .cm-string-2{color:#f50}.cm-s-default .cm-meta,.cm-s-default .cm-qualifier{color:#555}.cm-s-default .cm-builtin{color:#30a}.cm-s-default .cm-bracket{color:#666}.cm-s-default .cm-tag{color:#170}.cm-s-default .cm-attribute{color:#00c}.cm-s-default .cm-header{color:#00f}.cm-s-default .cm-quote{color:#090}.cm-s-default .cm-hr{color:#666}.cm-s-default .cm-link{color:#00c}.cm-negative{color:#d44}.cm-positive{color:#292}.cm-header,.cm-strong{font-weight:700}.cm-em{font-style:italic}.cm-link{text-decoration:underline}.cm-strikethrough{text-decoration:line-through}.cm-invalidchar,.cm-s-default .cm-error{color:red}.CodeMirror-composing{border-bottom:2px solid}div.CodeMirror span.CodeMirror-matchingbracket{color:#0f0}div.CodeMirror span.CodeMirror-nonmatchingbracket{color:#f22}.CodeMirror-matchingtag{background:rgba(255,150,0,.3)}.CodeMirror-activeline-background{background:#e8f2ff}.CodeMirror{background:#fff;overflow:hidden;position:relative}.CodeMirror-scroll{height:100%;margin-bottom:-30px;margin-right:-30px;outline:none;overflow:scroll!important;padding-bottom:30px;position:relative}.CodeMirror-sizer{border-right:30px solid transparent;position:relative}.CodeMirror-gutter-filler,.CodeMirror-hscrollbar,.CodeMirror-scrollbar-filler,.CodeMirror-vscrollbar{display:none;position:absolute;z-index:6}.CodeMirror-vscrollbar{overflow-x:hidden;overflow-y:scroll;right:0;top:0}.CodeMirror-hscrollbar{bottom:0;left:0;overflow-x:scroll;overflow-y:hidden}.CodeMirror-scrollbar-filler{right:0;bottom:0}.CodeMirror-gutter-filler{left:0;bottom:0}.CodeMirror-gutters{min-height:100%;position:absolute;left:0;top:0;z-index:3}.CodeMirror-gutter{display:inline-block;height:100%;margin-bottom:-30px;vertical-align:top;white-space:normal;*zoom:1;*display:inline}.CodeMirror-gutter-wrapper{background:none!important;border:none!important;position:absolute;z-index:4}.CodeMirror-gutter-background{position:absolute;top:0;bottom:0;z-index:4}.CodeMirror-gutter-elt{cursor:default;position:absolute;z-index:4}.CodeMirror-gutter-wrapper{user-select:none}.CodeMirror-lines{cursor:text;min-height:1px}.CodeMirror pre{-webkit-tap-highlight-color:transparent;background:transparent;border-radius:0;border-width:0;color:inherit;font-family:inherit;font-size:inherit;font-variant-ligatures:none;line-height:inherit;margin:0;overflow:visible;position:relative;white-space:pre;word-wrap:normal;z-index:2}.CodeMirror-wrap pre{word-wrap:break-word;white-space:pre-wrap;word-break:normal}.CodeMirror-linebackground{position:absolute;left:0;right:0;top:0;bottom:0;z-index:0}.CodeMirror-linewidget{overflow:auto;position:relative;z-index:2}.CodeMirror-code{outline:none}.CodeMirror-gutter,.CodeMirror-gutters,.CodeMirror-linenumber,.CodeMirror-scroll,.CodeMirror-sizer{box-sizing:content-box}.CodeMirror-measure{height:0;overflow:hidden;position:absolute;visibility:hidden;width:100%}.CodeMirror-cursor{position:absolute}.CodeMirror-measure pre{position:static}div.CodeMirror-cursors{position:relative;visibility:hidden;z-index:3}.CodeMirror-focused div.CodeMirror-cursors,div.CodeMirror-dragcursors{visibility:visible}.CodeMirror-selected{background:#d9d9d9}.CodeMirror-focused .CodeMirror-selected{background:#d7d4f0}.CodeMirror-crosshair{cursor:crosshair}.CodeMirror-line::selection,.CodeMirror-line>span::selection,.CodeMirror-line>span>span::selection{background:#d7d4f0}.CodeMirror-line::-moz-selection,.CodeMirror-line>span::-moz-selection,.CodeMirror-line>span>span::-moz-selection{background:#d7d4f0}.cm-searching{background:#ffa;background:rgba(255,255,0,.4)}.CodeMirror span{*vertical-align:text-bottom}.cm-force-border{padding-right:.1px}@media print{.CodeMirror div.CodeMirror-cursors{visibility:hidden}}.cm-tab-wrap-hack:after{content:""}span.CodeMirror-selectedtext{background:none}.CodeMirror-dialog{background:inherit;color:inherit;left:0;right:0;overflow:hidden;padding:.1em .8em;position:absolute;z-index:15}.CodeMirror-dialog-top{border-bottom:1px solid #eee;top:0}.CodeMirror-dialog-bottom{border-top:1px solid #eee;bottom:0}.CodeMirror-dialog input{background:transparent;border:1px solid #d3d6db;color:inherit;font-family:monospace;outline:none;width:20em}.CodeMirror-dialog button{font-size:70%} 3 | .CodeMirror-foldmarker{color:#00f;cursor:pointer;font-family:arial;line-height:.3;text-shadow:#b9f 1px 1px 2px,#b9f -1px -1px 2px,#b9f 1px -1px 2px,#b9f -1px 1px 2px}.CodeMirror-foldgutter{width:.7em}.CodeMirror-foldgutter-folded,.CodeMirror-foldgutter-open{cursor:pointer}.CodeMirror-foldgutter-open:after{content:"\25BE"}.CodeMirror-foldgutter-folded:after{content:"\25B8"} 4 | .CodeMirror-info{background:#fff;border-radius:2px;box-shadow:0 1px 3px rgba(0,0,0,.45);box-sizing:border-box;color:#555;font-family:system,-apple-system,San Francisco,\.SFNSDisplay-Regular,Segoe UI,Segoe,Segoe WP,Helvetica Neue,helvetica,Lucida Grande,arial,sans-serif;font-size:13px;line-height:16px;margin:8px -8px;max-width:400px;opacity:0;overflow:hidden;padding:8px;position:fixed;transition:opacity .15s;z-index:50}.CodeMirror-info :first-child{margin-top:0}.CodeMirror-info :last-child{margin-bottom:0}.CodeMirror-info p{margin:1em 0}.CodeMirror-info .info-description{color:#777;line-height:16px;margin-top:1em;max-height:80px;overflow:hidden}.CodeMirror-info .info-deprecation{background:#fffae8;box-shadow:inset 0 1px 1px -1px #bfb063;color:#867f70;line-height:16px;margin:8px -8px -8px;max-height:80px;overflow:hidden;padding:8px}.CodeMirror-info .info-deprecation-label{color:#c79b2e;cursor:default;display:block;font-size:9px;font-weight:700;letter-spacing:1px;line-height:1;padding-bottom:5px;text-transform:uppercase;user-select:none}.CodeMirror-info .info-deprecation-label+*{margin-top:0}.CodeMirror-info a{text-decoration:none}.CodeMirror-info a:hover{text-decoration:underline}.CodeMirror-info .type-name{color:#ca9800}.CodeMirror-info .field-name{color:#1f61a0}.CodeMirror-info .enum-value{color:#0b7fc7}.CodeMirror-info .arg-name{color:#8b2bb9}.CodeMirror-info .directive-name{color:#b33086} 5 | .CodeMirror-jump-token{text-decoration:underline;cursor:pointer} 6 | .CodeMirror-lint-markers{width:16px}.CodeMirror-lint-tooltip{background-color:infobackground;border-radius:4px 4px 4px 4px;border:1px solid #000;color:infotext;font-family:monospace;font-size:10pt;max-width:600px;opacity:0;overflow:hidden;padding:2px 5px;position:fixed;transition:opacity .4s;white-space:pre-wrap;z-index:100}.CodeMirror-lint-mark-error,.CodeMirror-lint-mark-warning{background-position:0 100%;background-repeat:repeat-x}.CodeMirror-lint-mark-error{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJDw4cOCW1/KIAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAHElEQVQI12NggIL/DAz/GdA5/xkY/qPKMDAwAADLZwf5rvm+LQAAAABJRU5ErkJggg==")}.CodeMirror-lint-mark-warning{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJFhQXEbhTg7YAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAMklEQVQI12NkgIIvJ3QXMjAwdDN+OaEbysDA4MPAwNDNwMCwiOHLCd1zX07o6kBVGQEAKBANtobskNMAAAAASUVORK5CYII=")}.CodeMirror-lint-marker-error,.CodeMirror-lint-marker-warning{background-position:50%;background-repeat:no-repeat;cursor:pointer;display:inline-block;height:16px;position:relative;vertical-align:middle;width:16px}.CodeMirror-lint-message-error,.CodeMirror-lint-message-warning{background-position:0 0;background-repeat:no-repeat;padding-left:18px}.CodeMirror-lint-marker-error,.CodeMirror-lint-message-error{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAHlBMVEW7AAC7AACxAAC7AAC7AAAAAAC4AAC5AAD///+7AAAUdclpAAAABnRSTlMXnORSiwCK0ZKSAAAATUlEQVR42mWPOQ7AQAgDuQLx/z8csYRmPRIFIwRGnosRrpamvkKi0FTIiMASR3hhKW+hAN6/tIWhu9PDWiTGNEkTtIOucA5Oyr9ckPgAWm0GPBog6v4AAAAASUVORK5CYII=")}.CodeMirror-lint-marker-warning,.CodeMirror-lint-message-warning{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAANlBMVEX/uwDvrwD/uwD/uwD/uwD/uwD/uwD/uwD/uwD6twD/uwAAAADurwD2tQD7uAD+ugAAAAD/uwDhmeTRAAAADHRSTlMJ8mN1EYcbmiixgACm7WbuAAAAVklEQVR42n3PUQqAIBBFUU1LLc3u/jdbOJoW1P08DA9Gba8+YWJ6gNJoNYIBzAA2chBth5kLmG9YUoG0NHAUwFXwO9LuBQL1giCQb8gC9Oro2vp5rncCIY8L8uEx5ZkAAAAASUVORK5CYII=")}.CodeMirror-lint-marker-multiple{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAMAAADzjKfhAAAACVBMVEUAAAAAAAC/v7914kyHAAAAAXRSTlMAQObYZgAAACNJREFUeNo1ioEJAAAIwmz/H90iFFSGJgFMe3gaLZ0od+9/AQZ0ADosbYraAAAAAElFTkSuQmCC");background-position:100% 100%;background-repeat:no-repeat;width:100%;height:100%} 7 | .graphiql-container .spinner-container{height:36px;left:50%;position:absolute;top:50%;transform:translate(-50%,-50%);width:36px;z-index:10}.graphiql-container .spinner{animation:rotation .6s linear infinite;border-radius:100%;border:6px solid hsla(0,0%,58.8%,.15);border-top-color:hsla(0,0%,58.8%,.8);display:inline-block;height:24px;position:absolute;vertical-align:middle;width:24px}@keyframes rotation{0%{transform:rotate(0deg)}to{transform:rotate(359deg)}} 8 | .CodeMirror-hints{background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.45);font-family:Consolas,Inconsolata,Droid Sans Mono,Monaco,monospace;font-size:13px;list-style:none;margin:0;max-height:14.5em;overflow:hidden;overflow-y:auto;padding:0;position:absolute;z-index:10}.CodeMirror-hint{border-top:1px solid #f7f7f7;color:#141823;cursor:pointer;margin:0;max-width:300px;overflow:hidden;padding:2px 6px;white-space:pre}li.CodeMirror-hint-active{background-color:#08f;border-top-color:#fff;color:#fff}.CodeMirror-hint-information{border-top:1px solid silver;max-width:300px;padding:4px 6px;position:relative;z-index:1}.CodeMirror-hint-information:first-child{border-bottom:1px solid silver;border-top:none;margin-bottom:-1px}.CodeMirror-hint-deprecation{background:#fffae8;box-shadow:inset 0 1px 1px -1px #bfb063;color:#867f70;font-family:system,-apple-system,San Francisco,\.SFNSDisplay-Regular,Segoe UI,Segoe,Segoe WP,Helvetica Neue,helvetica,Lucida Grande,arial,sans-serif;font-size:13px;line-height:16px;margin-top:4px;max-height:80px;overflow:hidden;padding:6px}.CodeMirror-hint-deprecation .deprecation-label{color:#c79b2e;cursor:default;display:block;font-size:9px;font-weight:700;letter-spacing:1px;line-height:1;padding-bottom:5px;text-transform:uppercase;user-select:none}.CodeMirror-hint-deprecation .deprecation-label+*{margin-top:0}.CodeMirror-hint-deprecation :last-child{margin-bottom:0} 9 | .graphiql-container .doc-explorer{background:#fff}.graphiql-container .doc-explorer-title-bar,.graphiql-container .history-title-bar{cursor:default;display:flex;height:34px;line-height:14px;padding:8px 8px 5px;position:relative;user-select:none}.graphiql-container .doc-explorer-title,.graphiql-container .history-title{flex:1;font-weight:700;overflow-x:hidden;padding:10px 0 10px 10px;text-align:center;text-overflow:ellipsis;user-select:text;white-space:nowrap}.graphiql-container .doc-explorer-back{color:#3b5998;cursor:pointer;margin:-7px 0 -6px -8px;overflow-x:hidden;padding:17px 12px 16px 16px;text-overflow:ellipsis;white-space:nowrap;background:0;border:0;line-height:14px}.doc-explorer-narrow .doc-explorer-back{width:0}.graphiql-container .doc-explorer-back:before{border-left:2px solid #3b5998;border-top:2px solid #3b5998;content:"";display:inline-block;height:9px;margin:0 3px -1px 0;position:relative;transform:rotate(-45deg);width:9px}.graphiql-container .doc-explorer-rhs{position:relative}.graphiql-container .doc-explorer-contents,.graphiql-container .history-contents{background-color:#fff;border-top:1px solid #d6d6d6;bottom:0;left:0;overflow-y:auto;padding:20px 15px;position:absolute;right:0;top:47px}.graphiql-container .doc-explorer-contents{min-width:300px}.graphiql-container .doc-type-description blockquote:first-child,.graphiql-container .doc-type-description p:first-child{margin-top:0}.graphiql-container .doc-explorer-contents a{cursor:pointer;text-decoration:none}.graphiql-container .doc-explorer-contents a:hover{text-decoration:underline}.graphiql-container .doc-value-description>:first-child{margin-top:4px}.graphiql-container .doc-value-description>:last-child{margin-bottom:4px}.graphiql-container .doc-category code,.graphiql-container .doc-category pre,.graphiql-container .doc-type-description code,.graphiql-container .doc-type-description pre{--saf-0:rgba(var(--sk_foreground_low,29,28,29),0.13);font-size:12px;line-height:1.50001;font-variant-ligatures:none;white-space:pre;white-space:pre-wrap;word-wrap:break-word;word-break:normal;-webkit-tab-size:4;-moz-tab-size:4;tab-size:4}.graphiql-container .doc-category code,.graphiql-container .doc-type-description code{padding:2px 3px 1px;border:1px solid var(--saf-0);border-radius:3px;background-color:rgba(var(--sk_foreground_min,29,28,29),.04);color:#e01e5a;background-color:#fff}.graphiql-container .doc-category{margin:20px 0}.graphiql-container .doc-category-title{border-bottom:1px solid #e0e0e0;color:#777;cursor:default;font-size:14px;font-variant:small-caps;font-weight:700;letter-spacing:1px;margin:0 -15px 10px 0;padding:10px 0;user-select:none}.graphiql-container .doc-category-item{margin:12px 0;color:#555}.graphiql-container .keyword{color:#b11a04}.graphiql-container .type-name{color:#ca9800}.graphiql-container .field-name{color:#1f61a0}.graphiql-container .field-short-description{color:#666;margin-left:5px;overflow:hidden;text-overflow:ellipsis}.graphiql-container .enum-value{color:#0b7fc7}.graphiql-container .arg-name{color:#8b2bb9}.graphiql-container .arg{display:block;margin-left:1em}.graphiql-container .arg:first-child:last-child,.graphiql-container .arg:first-child:nth-last-child(2),.graphiql-container .arg:first-child:nth-last-child(2)~.arg{display:inherit;margin:inherit}.graphiql-container .arg:first-child:nth-last-child(2):after{content:", "}.graphiql-container .arg-default-value{color:#43a047}.graphiql-container .doc-deprecation{background:#fffae8;box-shadow:inset 0 0 1px #bfb063;color:#867f70;line-height:16px;margin:8px -8px;max-height:80px;overflow:hidden;padding:8px;border-radius:3px}.graphiql-container .doc-deprecation:before{content:"Deprecated:";color:#c79b2e;cursor:default;display:block;font-size:9px;font-weight:700;letter-spacing:1px;line-height:1;padding-bottom:5px;text-transform:uppercase;user-select:none}.graphiql-container .doc-deprecation>:first-child{margin-top:0}.graphiql-container .doc-deprecation>:last-child{margin-bottom:0}.graphiql-container .show-btn{-webkit-appearance:initial;display:block;border-radius:3px;border:1px solid #ccc;text-align:center;padding:8px 12px 10px;width:100%;box-sizing:border-box;background:#fbfcfc;color:#555;cursor:pointer}.graphiql-container .search-box{border-bottom:1px solid #d3d6db;display:flex;align-items:center;font-size:14px;margin:-15px -15px 12px 0;position:relative}.graphiql-container .search-box-icon{cursor:pointer;display:block;font-size:24px;transform:rotate(-45deg);user-select:none}.graphiql-container .search-box .search-box-clear{background-color:#d0d0d0;border-radius:12px;color:#fff;cursor:pointer;font-size:11px;padding:1px 5px 2px;position:absolute;right:3px;user-select:none;border:0}.graphiql-container .search-box .search-box-clear:hover{background-color:#b9b9b9}.graphiql-container .search-box>input{border:none;box-sizing:border-box;font-size:14px;outline:none;padding:6px 24px 8px 20px;width:100%}.graphiql-container .error-container{font-weight:700;left:0;letter-spacing:1px;opacity:.5;position:absolute;right:0;text-align:center;text-transform:uppercase;top:50%;transform:translateY(-50%)} 10 | .graphiql-container .history-contents{font-family:Consolas,Inconsolata,Droid Sans Mono,Monaco,monospace;margin:0;padding:0}.graphiql-container .history-contents li{align-items:center;display:flex;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin:0;padding:8px;border-bottom:1px solid #e0e0e0}.graphiql-container .history-contents li button:not(.history-label){display:none;margin-left:10px}.graphiql-container .history-contents li:focus-within button:not(.history-label),.graphiql-container .history-contents li:hover button:not(.history-label){display:inline-block}.graphiql-container .history-contents button,.graphiql-container .history-contents input{padding:0;background:0;border:0;font-size:inherit;font-family:inherit;line-height:14px;color:inherit}.graphiql-container .history-contents input{flex-grow:1}.graphiql-container .history-contents input::placeholder{color:inherit}.graphiql-container .history-contents button{cursor:pointer;text-align:left}.graphiql-container .history-contents .history-label{flex-grow:1;overflow:hidden;text-overflow:ellipsis} 11 | 12 | /*# sourceMappingURL=graphiql.min.css.map*/ -------------------------------------------------------------------------------- /datasette_graphql/static/react.production.17.0.2.min.js: -------------------------------------------------------------------------------- 1 | /** @license React v17.0.2 2 | * react.production.min.js 3 | * 4 | * Copyright (c) Facebook, Inc. and its affiliates. 5 | * 6 | * This source code is licensed under the MIT license found in the 7 | * LICENSE file in the root directory of this source tree. 8 | */ 9 | (function(){'use strict';(function(c,x){"object"===typeof exports&&"undefined"!==typeof module?x(exports):"function"===typeof define&&define.amd?define(["exports"],x):(c=c||self,x(c.React={}))})(this,function(c){function x(a){if(null===a||"object"!==typeof a)return null;a=Y&&a[Y]||a["@@iterator"];return"function"===typeof a?a:null}function y(a){for(var b="https://reactjs.org/docs/error-decoder.html?invariant="+a,e=1;e>>1,f=a[c];if(void 0!==f&&0E(g,e))void 0!==h&&0>E(h,g)?(a[c]=h,a[k]=e,c=k):(a[c]=g,a[d]=e,c=d);else if(void 0!==h&&0>E(h,e))a[c]=h,a[k]=e,c=k;else break a}}return b}return null}function E(a,b){var e=a.sortIndex-b.sortIndex;return 0!==e?e:a.id-b.id}function P(a){for(var b=p(r);null!==b;){if(null===b.callback)F(r);else if(b.startTime<=a)F(r),b.sortIndex=b.expirationTime,O(q,b);else break;b=p(r)}} 16 | function Q(a){z=!1;P(a);if(!u)if(null!==p(q))u=!0,A(R);else{var b=p(r);null!==b&&G(Q,b.startTime-a)}}function R(a,b){u=!1;z&&(z=!1,S());H=!0;var e=g;try{P(b);for(m=p(q);null!==m&&(!(m.expirationTime>b)||a&&!T());){var c=m.callback;if("function"===typeof c){m.callback=null;g=m.priorityLevel;var f=c(m.expirationTime<=b);b=t();"function"===typeof f?m.callback=f:m===p(q)&&F(q);P(b)}else F(q);m=p(q)}if(null!==m)var d=!0;else{var n=p(r);null!==n&&G(Q,n.startTime-b);d=!1}return d}finally{m=null,g=e,H=!1}} 17 | var w=60103,ha=60106;c.Fragment=60107;c.StrictMode=60108;c.Profiler=60114;var ka=60109,la=60110,ma=60112;c.Suspense=60113;var na=60115,oa=60116;if("function"===typeof Symbol&&Symbol.for){var d=Symbol.for;w=d("react.element");ha=d("react.portal");c.Fragment=d("react.fragment");c.StrictMode=d("react.strict_mode");c.Profiler=d("react.profiler");ka=d("react.provider");la=d("react.context");ma=d("react.forward_ref");c.Suspense=d("react.suspense");na=d("react.memo");oa=d("react.lazy")}var Y="function"=== 18 | typeof Symbol&&Symbol.iterator,ya=Object.prototype.hasOwnProperty,U=Object.assign||function(a,b){if(null==a)throw new TypeError("Object.assign target cannot be null or undefined");for(var e=Object(a),c=1;c=ta};d=function(){};V=function(a){0>a||125d?(a.sortIndex= 25 | c,O(r,a),null===p(q)&&a===p(r)&&(z?S():z=!0,G(Q,c-d))):(a.sortIndex=e,O(q,a),u||H||(u=!0,A(R)));return a},unstable_cancelCallback:function(a){a.callback=null},unstable_wrapCallback:function(a){var b=g;return function(){var c=g;g=b;try{return a.apply(this,arguments)}finally{g=c}}},unstable_getCurrentPriorityLevel:function(){return g},get unstable_shouldYield(){return T},unstable_requestPaint:d,unstable_continueExecution:function(){u||H||(u=!0,A(R))},unstable_pauseExecution:function(){},unstable_getFirstCallbackNode:function(){return p(q)}, 26 | get unstable_now(){return t},get unstable_forceFrameRate(){return V},unstable_Profiling:null},SchedulerTracing:{__proto__:null,__interactionsRef:null,__subscriberRef:null,unstable_clear:function(a){return a()},unstable_getCurrent:function(){return null},unstable_getThreadID:function(){return++Ea},unstable_trace:function(a,b,c){return c()},unstable_wrap:function(a){return a},unstable_subscribe:function(a){},unstable_unsubscribe:function(a){}}};c.Children={map:D,forEach:function(a,b,c){D(a,function(){b.apply(this, 27 | arguments)},c)},count:function(a){var b=0;D(a,function(){b++});return b},toArray:function(a){return D(a,function(a){return a})||[]},only:function(a){if(!M(a))throw Error(y(143));return a}};c.Component=v;c.PureComponent=K;c.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=d;c.cloneElement=function(a,b,c){if(null===a||void 0===a)throw Error(y(267,a));var d=U({},a.props),e=a.key,g=a.ref,n=a._owner;if(null!=b){void 0!==b.ref&&(g=b.ref,n=L.current);void 0!==b.key&&(e=""+b.key);if(a.type&&a.type.defaultProps)var k= 28 | a.type.defaultProps;for(h in b)ea.call(b,h)&&!fa.hasOwnProperty(h)&&(d[h]=void 0===b[h]&&void 0!==k?k[h]:b[h])}var h=arguments.length-2;if(1===h)d.children=c;else if(1 2 | 3 | 4 | GraphiQL 5 | 16 | {% for filename in graphiql_css %} 17 | 18 | {% endfor %} 19 | {% for filename in graphiql_js %} 20 | 21 | {% endfor %} 22 | 23 | 24 |
Loading...
25 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /datasette_graphql/utils.py: -------------------------------------------------------------------------------- 1 | from base64 import b64decode, b64encode 2 | from collections import namedtuple 3 | from datasette.utils import await_me_maybe 4 | from datasette.plugins import pm 5 | from enum import Enum 6 | import graphene 7 | from graphene.types import generic 8 | import json 9 | import keyword 10 | import urllib 11 | import re 12 | import sqlite_utils 13 | import textwrap 14 | import time 15 | 16 | TableMetadata = namedtuple( 17 | "TableMetadata", 18 | ( 19 | "columns", 20 | "foreign_keys", 21 | "fks_back", 22 | "pks", 23 | "supports_fts", 24 | "is_view", 25 | "graphql_name", 26 | "graphql_columns", 27 | ), 28 | ) 29 | 30 | 31 | class Bytes(graphene.Scalar): 32 | # Replace with graphene.Base64 after graphene 3.0 is released 33 | @staticmethod 34 | def serialize(value): 35 | if isinstance(value, bytes): 36 | return b64encode(value).decode("utf-8") 37 | return value 38 | 39 | @classmethod 40 | def parse_literal(cls, node): 41 | if isinstance(node, ast.StringValue): 42 | return cls.parse_value(node.value) 43 | 44 | @staticmethod 45 | def parse_value(value): 46 | if isinstance(value, bytes): 47 | return value 48 | else: 49 | return b64decode(value) 50 | 51 | 52 | types = { 53 | str: graphene.String(), 54 | float: graphene.Float(), 55 | int: graphene.Int(), 56 | bytes: Bytes(), 57 | } 58 | 59 | 60 | class PageInfo(graphene.ObjectType): 61 | endCursor = graphene.String() 62 | hasNextPage = graphene.Boolean() 63 | 64 | 65 | class DatabaseSchema: 66 | def __init__(self, schema, table_classes, table_collection_classes): 67 | self.schema = schema 68 | self.table_classes = table_classes 69 | self.table_collection_classes = table_collection_classes 70 | 71 | 72 | # cache keys are (database, schema_version) tuples 73 | _schema_cache = {} 74 | 75 | 76 | async def schema_for_database_via_cache(datasette, database=None): 77 | db = datasette.get_database(database) 78 | schema_version = (await db.execute("PRAGMA schema_version")).first()[0] 79 | cache_key = (database, schema_version) 80 | if cache_key not in _schema_cache: 81 | schema = await schema_for_database(datasette, database) 82 | _schema_cache[cache_key] = schema 83 | # Delete other cached versions of this database 84 | to_delete = [ 85 | key for key in _schema_cache if key[0] == database and key != cache_key 86 | ] 87 | for key in to_delete: 88 | del _schema_cache[key] 89 | return _schema_cache[cache_key] 90 | 91 | 92 | async def schema_for_database(datasette, database=None): 93 | db = datasette.get_database(database) 94 | hidden_tables = await db.hidden_table_names() 95 | 96 | # Perform all introspection in a single call to the execute_fn thread 97 | table_metadata = await db.execute_fn( 98 | lambda conn: introspect_tables(conn, datasette, db.name) 99 | ) 100 | 101 | # Construct the tableFilter classes 102 | table_filters = { 103 | table: make_table_filter_class(table, meta) 104 | for table, meta in table_metadata.items() 105 | } 106 | # And the table_collection_kwargs 107 | table_collection_kwargs = {} 108 | 109 | for table, meta in table_metadata.items(): 110 | column_names = meta.graphql_columns.values() 111 | options = list(zip(column_names, column_names)) 112 | sort_enum = graphene.Enum.from_enum( 113 | Enum("{}Sort".format(meta.graphql_name), options), 114 | description="Sort by this column", 115 | ) 116 | sort_desc_enum = graphene.Enum.from_enum( 117 | Enum("{}SortDesc".format(meta.graphql_name), options), 118 | description="Sort by this column descending", 119 | ) 120 | kwargs = dict( 121 | filter=graphene.List( 122 | table_filters[table], 123 | description='Filters e.g. {name: {eq: "datasette"}}', 124 | ), 125 | where=graphene.String( 126 | description="Extra SQL where clauses, e.g. \"name='datasette'\"" 127 | ), 128 | first=graphene.Int(description="Number of results to return"), 129 | after=graphene.String( 130 | description="Start at this pagination cursor (from tableInfo { endCursor })" 131 | ), 132 | sort=sort_enum(), 133 | sort_desc=sort_desc_enum(), 134 | ) 135 | if meta.supports_fts: 136 | kwargs["search"] = graphene.String(description="Search for this term") 137 | table_collection_kwargs[table] = kwargs 138 | 139 | # For each table, expose a graphene.List 140 | to_add = [] 141 | table_classes = {} 142 | table_collection_classes = {} 143 | 144 | for table, meta in table_metadata.items(): 145 | table_name = meta.graphql_name 146 | if table in hidden_tables: 147 | continue 148 | 149 | # (columns, foreign_keys, fks_back, pks, supports_fts) = table_meta 150 | table_node_class = await make_table_node_class( 151 | datasette, 152 | db, 153 | table, 154 | table_classes, 155 | table_filters, 156 | table_metadata, 157 | table_collection_classes, 158 | table_collection_kwargs, 159 | ) 160 | table_classes[table] = table_node_class 161 | 162 | # We also need a table collection class - this is the thing with the 163 | # nodes, edges, pageInfo and totalCount fields for that table 164 | table_collection_class = make_table_collection_class( 165 | table, table_node_class, meta 166 | ) 167 | table_collection_classes[table] = table_collection_class 168 | to_add.append( 169 | ( 170 | meta.graphql_name, 171 | graphene.Field( 172 | table_collection_class, 173 | **table_collection_kwargs[table], 174 | description="Rows from the {} {}".format( 175 | table, "view" if table_metadata[table].is_view else "table" 176 | ) 177 | ), 178 | ) 179 | ) 180 | to_add.append( 181 | ( 182 | "resolve_{}".format(meta.graphql_name), 183 | make_table_resolver( 184 | datasette, db.name, table, table_classes, table_metadata 185 | ), 186 | ) 187 | ) 188 | # *_row field 189 | table_row_kwargs = dict(table_collection_kwargs[table]) 190 | table_row_kwargs.pop("first") 191 | # Add an argument for each primary key 192 | for pk in meta.pks: 193 | if pk == "rowid" and pk not in meta.columns: 194 | pk_column_type = graphene.Int() 195 | else: 196 | pk_column_type = types[meta.columns[pk]] 197 | table_row_kwargs[meta.graphql_columns.get(pk, pk)] = pk_column_type 198 | 199 | to_add.append( 200 | ( 201 | "{}_row".format(meta.graphql_name), 202 | graphene.Field( 203 | table_node_class, 204 | args=table_row_kwargs, 205 | description="Single row from the {} {}".format( 206 | table, "view" if table_metadata[table].is_view else "table" 207 | ), 208 | ), 209 | ) 210 | ) 211 | to_add.append( 212 | ( 213 | "resolve_{}_row".format(meta.graphql_name), 214 | make_table_resolver( 215 | datasette, 216 | db.name, 217 | table, 218 | table_classes, 219 | table_metadata, 220 | pk_args=meta.pks, 221 | return_first_row=True, 222 | ), 223 | ) 224 | ) 225 | 226 | if not to_add: 227 | # Empty schema throws a 500 error, so add something here 228 | to_add.append(("empty", graphene.String())) 229 | to_add.append(("resolve_empty", lambda a, b: "schema")) 230 | 231 | for extra_fields in pm.hook.graphql_extra_fields( 232 | datasette=datasette, database=db.name 233 | ): 234 | extra_tuples = await await_me_maybe(extra_fields) 235 | if extra_tuples: 236 | to_add.extend(extra_tuples) 237 | 238 | Query = type( 239 | "Query", 240 | (graphene.ObjectType,), 241 | {key: value for key, value in to_add}, 242 | ) 243 | database_schema = DatabaseSchema( 244 | schema=graphene.Schema( 245 | query=Query, 246 | auto_camelcase=(datasette.plugin_config("datasette-graphql") or {}).get( 247 | "auto_camelcase", False 248 | ), 249 | ), 250 | table_classes=table_classes, 251 | table_collection_classes=table_collection_classes, 252 | ) 253 | return database_schema 254 | 255 | 256 | def make_table_collection_class(table, table_class, meta): 257 | table_name = meta.graphql_name 258 | 259 | class _Edge(graphene.ObjectType): 260 | cursor = graphene.String() 261 | node = graphene.Field(table_class) 262 | 263 | class Meta: 264 | name = "{}Edge".format(table_name) 265 | 266 | class _TableCollection(graphene.ObjectType): 267 | totalCount = graphene.Int() 268 | pageInfo = graphene.Field(PageInfo) 269 | nodes = graphene.List(table_class) 270 | edges = graphene.List(_Edge) 271 | 272 | def resolve_totalCount(parent, info): 273 | return parent["filtered_table_rows_count"] 274 | 275 | def resolve_nodes(parent, info): 276 | return parent["rows"] 277 | 278 | def resolve_edges(parent, info): 279 | return [ 280 | { 281 | "cursor": path_from_row_pks(row, meta.pks, use_rowid=not meta.pks), 282 | "node": row, 283 | } 284 | for row in parent["rows"] 285 | ] 286 | 287 | def resolve_pageInfo(parent, info): 288 | return { 289 | "endCursor": parent["next"], 290 | "hasNextPage": parent["next"] is not None, 291 | } 292 | 293 | class Meta: 294 | name = "{}Collection".format(table_name) 295 | 296 | return _TableCollection 297 | 298 | 299 | class StringOperations(graphene.InputObjectType): 300 | exact = graphene.String(name="eq", description="Exact match") 301 | not_ = graphene.String(name="not", description="Not exact match") 302 | contains = graphene.String(description="String contains") 303 | endswith = graphene.String(description="String ends with") 304 | startswith = graphene.String(description="String starts with") 305 | gt = graphene.String(description="is greater than") 306 | gte = graphene.String(description="is greater than or equal to") 307 | lt = graphene.String(description="is less than") 308 | lte = graphene.String(description="is less than or equal to") 309 | like = graphene.String(description=r"is like (% for wildcards)") 310 | notlike = graphene.String(description="is not like") 311 | glob = graphene.String(description="glob matches (* for wildcards)") 312 | in_ = graphene.List(graphene.String, name="in", description="in this list") 313 | notin = graphene.List(graphene.String, description="not in this list") 314 | arraycontains = graphene.String(description="JSON array contains this value") 315 | date = graphene.String(description="Value is a datetime on this date") 316 | isnull = graphene.Boolean(description="Value is null") 317 | notnull = graphene.Boolean(description="Value is not null") 318 | isblank = graphene.Boolean(description="Value is null or blank") 319 | notblank = graphene.Boolean(description="Value is not null or blank") 320 | 321 | 322 | class IntegerOperations(graphene.InputObjectType): 323 | exact = graphene.Int(name="eq", description="Exact match") 324 | not_ = graphene.Int(name="not", description="Not exact match") 325 | gt = graphene.Int(description="is greater than") 326 | gte = graphene.Int(description="is greater than or equal to") 327 | lt = graphene.Int(description="is less than") 328 | lte = graphene.Int(description="is less than or equal to") 329 | in_ = graphene.List(graphene.Int, name="in", description="in this list") 330 | notin = graphene.List(graphene.Int, description="not in this list") 331 | arraycontains = graphene.Int(description="JSON array contains this value") 332 | isnull = graphene.Boolean(description="Value is null") 333 | notnull = graphene.Boolean(description="Value is not null") 334 | isblank = graphene.Boolean(description="Value is null or blank") 335 | notblank = graphene.Boolean(description="Value is not null or blank") 336 | 337 | 338 | types_to_operations = { 339 | str: StringOperations, 340 | int: IntegerOperations, 341 | float: IntegerOperations, 342 | } 343 | 344 | 345 | def make_table_filter_class(table, meta): 346 | return type( 347 | "{}Filter".format(meta.graphql_name), 348 | (graphene.InputObjectType,), 349 | { 350 | meta.graphql_columns[column]: ( 351 | types_to_operations.get(column_type) or StringOperations 352 | )() 353 | for column, column_type in meta.columns.items() 354 | }, 355 | ) 356 | 357 | 358 | async def make_table_node_class( 359 | datasette, 360 | db, 361 | table, 362 | table_classes, 363 | table_filters, 364 | table_metadata, 365 | table_collection_classes, 366 | table_collection_kwargs, 367 | ): 368 | meta = table_metadata[table] 369 | fks_by_column = {fk.column: fk for fk in meta.foreign_keys} 370 | 371 | table_plugin_config = datasette.plugin_config( 372 | "datasette-graphql", database=db.name, table=table 373 | ) 374 | json_columns = [] 375 | if table_plugin_config and "json_columns" in table_plugin_config: 376 | json_columns = table_plugin_config["json_columns"] 377 | 378 | # Create a node class for this table 379 | plain_columns = [] 380 | fk_columns = [] 381 | table_dict = {} 382 | if meta.pks == ["rowid"]: 383 | table_dict["rowid"] = graphene.Int() 384 | plain_columns.append("rowid") 385 | 386 | columns_to_graphql_names = {} 387 | 388 | for colname, coltype in meta.columns.items(): 389 | graphql_name = meta.graphql_columns[colname] 390 | columns_to_graphql_names[colname] = graphql_name 391 | if colname in fks_by_column: 392 | fk = fks_by_column[colname] 393 | fk_columns.append((graphql_name, fk.other_table, fk.other_column)) 394 | table_dict[graphql_name] = graphene.Field( 395 | make_table_getter(table_classes, fk.other_table) 396 | ) 397 | table_dict["resolve_{}".format(graphql_name)] = make_fk_resolver( 398 | db, table, table_classes, fk 399 | ) 400 | else: 401 | plain_columns.append(graphql_name) 402 | if colname in json_columns: 403 | table_dict[graphql_name] = generic.GenericScalar() 404 | table_dict["resolve_{}".format(graphql_name)] = resolve_generic 405 | else: 406 | table_dict[graphql_name] = types[coltype] 407 | 408 | # Now add the backwards foreign key fields for related items 409 | fk_table_counts = {} 410 | for fk in meta.fks_back: 411 | fk_table_counts[fk.table] = fk_table_counts.get(fk.table, 0) + 1 412 | for fk in meta.fks_back: 413 | filter_class = table_filters[fk.table] 414 | if fk_table_counts[fk.table] > 1: 415 | field_name = "{}_by_{}_list".format( 416 | table_metadata[fk.table].graphql_name, 417 | table_metadata[fk.table].graphql_columns[fk.column], 418 | ) 419 | field_description = "Related rows from the {} table (by {})".format( 420 | fk.table, fk.column 421 | ) 422 | else: 423 | field_name = "{}_list".format(table_metadata[fk.table].graphql_name) 424 | field_description = "Related rows from the {} table".format(fk.table) 425 | table_dict[field_name] = graphene.Field( 426 | make_table_collection_getter(table_collection_classes, fk.table), 427 | **table_collection_kwargs[fk.table], 428 | description=field_description 429 | ) 430 | table_dict["resolve_{}".format(field_name)] = make_table_resolver( 431 | datasette, 432 | db.name, 433 | fk.table, 434 | table_classes, 435 | table_metadata, 436 | related_fk=fk, 437 | ) 438 | 439 | table_dict["from_row"] = classmethod( 440 | lambda cls, row: cls( 441 | **dict([(meta.graphql_columns.get(k, k), v) for k, v in dict(row).items()]) 442 | ) 443 | ) 444 | 445 | table_dict["graphql_name_for_column"] = columns_to_graphql_names.get 446 | 447 | async def example_query(): 448 | example_query_columns = [" {}".format(c) for c in plain_columns] 449 | # Now add the foreign key columns 450 | for graphql_name, other_table, other_column in fk_columns: 451 | label_column = await db.label_column_for_table(other_table) 452 | # Need to find outthe GraphQL names of other_column (the pk) and 453 | # label_column on other_table 454 | other_table_obj = table_classes[other_table] 455 | example_query_columns.append( 456 | " %s {\n %s\n%s }" 457 | % ( 458 | graphql_name, 459 | other_table_obj.graphql_name_for_column(other_column), 460 | ( 461 | " %s\n" 462 | % other_table_obj.graphql_name_for_column(label_column) 463 | ) 464 | if label_column 465 | else "", 466 | ) 467 | ) 468 | return ( 469 | textwrap.dedent( 470 | """ 471 | { 472 | TABLE { 473 | totalCount 474 | pageInfo { 475 | hasNextPage 476 | endCursor 477 | } 478 | nodes { 479 | COLUMNS 480 | } 481 | } 482 | } 483 | """ 484 | ) 485 | .strip() 486 | .replace("TABLE", meta.graphql_name) 487 | .replace("COLUMNS", "\n".join(example_query_columns)) 488 | ) 489 | 490 | table_dict["example_query"] = example_query 491 | 492 | return type(meta.graphql_name, (graphene.ObjectType,), table_dict) 493 | 494 | 495 | def make_table_resolver( 496 | datasette, 497 | database_name, 498 | table_name, 499 | table_classes, 500 | table_metadata, 501 | related_fk=None, 502 | pk_args=None, 503 | return_first_row=False, 504 | ): 505 | meta = table_metadata[table_name] 506 | 507 | async def resolve_table( 508 | root, 509 | info, 510 | filter=None, 511 | where=None, 512 | first=None, 513 | after=None, 514 | search=None, 515 | sort=None, 516 | sort_desc=None, 517 | **kwargs 518 | ): 519 | if first is None: 520 | first = 10 521 | 522 | if return_first_row: 523 | first = 1 524 | 525 | pairs = [] 526 | column_name_rev = {v: k for k, v in meta.graphql_columns.items()} 527 | for filter_ in filter or []: 528 | for column_name, operations in filter_.items(): 529 | for operation_name, value in operations.items(): 530 | if isinstance(value, list): 531 | value = ",".join(map(str, value)) 532 | pairs.append( 533 | [ 534 | "{}__{}".format( 535 | column_name_rev[column_name], operation_name.rstrip("_") 536 | ), 537 | value, 538 | ] 539 | ) 540 | 541 | if pk_args is not None: 542 | for pk in pk_args: 543 | if kwargs.get(pk) is not None: 544 | pairs.append([pk, kwargs[pk]]) 545 | 546 | qs = { 547 | "_nofacet": 1, 548 | } 549 | qs.update(pairs) 550 | if after: 551 | qs["_next"] = after 552 | qs["_size"] = first 553 | 554 | if search and meta.supports_fts: 555 | qs["_search"] = search 556 | 557 | if related_fk: 558 | related_column = meta.graphql_columns.get( 559 | related_fk.column, related_fk.column 560 | ) 561 | related_other_column = table_metadata[ 562 | related_fk.other_table 563 | ].graphql_columns.get(related_fk.other_column, related_fk.other_column) 564 | qs[related_column] = getattr(root, related_other_column) 565 | 566 | if where: 567 | qs["_where"] = where 568 | if sort: 569 | qs["_sort"] = column_name_rev[sort.value] 570 | elif sort_desc: 571 | qs["_sort_desc"] = column_name_rev[sort_desc.value] 572 | 573 | path_with_query_string = "/{}/{}.json?{}".format( 574 | database_name, table_name, urllib.parse.urlencode(qs) 575 | ) 576 | 577 | context = info.context 578 | if context and "time_started" in context: 579 | elapsed_ms = (time.monotonic() - context["time_started"]) * 1000 580 | if context["time_limit_ms"] and elapsed_ms > context["time_limit_ms"]: 581 | assert False, "Time limit exceeded: {:.2f}ms > {}ms - {}".format( 582 | elapsed_ms, context["time_limit_ms"], path_with_query_string 583 | ) 584 | context["num_queries_executed"] += 1 585 | if ( 586 | context["num_queries_limit"] 587 | and context["num_queries_executed"] > context["num_queries_limit"] 588 | ): 589 | assert False, "Query limit exceeded: {} > {} - {}".format( 590 | context["num_queries_executed"], 591 | context["num_queries_limit"], 592 | path_with_query_string, 593 | ) 594 | 595 | data = (await datasette.client.get(path_with_query_string)).json() 596 | data["rows"] = [dict(zip(data["columns"], row)) for row in data["rows"]] 597 | # If any cells are $base64, decode them into bytes objects 598 | for row in data["rows"]: 599 | for key, value in row.items(): 600 | if isinstance(value, dict) and value.get("$base64"): 601 | row[key] = b64decode(value["encoded"]) 602 | klass = table_classes[table_name] 603 | data["rows"] = [klass.from_row(r) for r in data["rows"]] 604 | if return_first_row: 605 | try: 606 | return data["rows"][0] 607 | except IndexError: 608 | return None 609 | else: 610 | return data 611 | 612 | return resolve_table 613 | 614 | 615 | def make_fk_resolver(db, table, table_classes, fk): 616 | async def resolve_foreign_key(parent, info): 617 | # retrieve the correct column from parent 618 | pk = getattr(parent, fk.column) 619 | sql = "select * from [{}] where [{}] = :v".format( 620 | fk.other_table, fk.other_column 621 | ) 622 | params = {"v": pk} 623 | results = await db.execute(sql, params) 624 | fk_class = table_classes[fk.other_table] 625 | try: 626 | return [fk_class.from_row(row) for row in results.rows][0] 627 | except IndexError: 628 | return None 629 | 630 | return resolve_foreign_key 631 | 632 | 633 | def make_table_getter(table_classes, table): 634 | def getter(): 635 | return table_classes[table] 636 | 637 | return getter 638 | 639 | 640 | def make_table_collection_getter(table_collection_classes, table): 641 | def getter(): 642 | return table_collection_classes[table] 643 | 644 | return getter 645 | 646 | 647 | def path_from_row_pks(row, pks, use_rowid, quote=True): 648 | """Generate an optionally URL-quoted unique identifier 649 | for a row from its primary keys.""" 650 | if use_rowid: 651 | bits = [row.rowid] 652 | else: 653 | bits = [getattr(row, pk) for pk in pks] 654 | if quote: 655 | bits = [urllib.parse.quote_plus(str(bit)) for bit in bits] 656 | else: 657 | bits = [str(bit) for bit in bits] 658 | 659 | return ",".join(bits) 660 | 661 | 662 | def introspect_tables(conn, datasette, db_name): 663 | db = sqlite_utils.Database(conn) 664 | 665 | table_names = db.table_names() 666 | view_names = db.view_names() 667 | 668 | table_metadata = {} 669 | table_namer = Namer("t") 670 | 671 | for table in table_names + view_names: 672 | datasette_table_metadata = datasette.table_metadata( 673 | table=table, database=db_name 674 | ) 675 | columns = db[table].columns_dict 676 | foreign_keys = [] 677 | pks = [] 678 | supports_fts = bool(datasette_table_metadata.get("fts_table")) 679 | fks_back = [] 680 | if hasattr(db[table], "foreign_keys"): 681 | # Views don't have .foreign_keys 682 | foreign_keys = [ 683 | fk 684 | for fk in db[table].foreign_keys 685 | # filter out keys to tables that do not exist 686 | if fk.other_table in table_names 687 | ] 688 | pks = db[table].pks 689 | supports_fts = bool(db[table].detect_fts()) or supports_fts 690 | # Gather all foreign keys pointing back here 691 | collected = [] 692 | for t in db.tables: 693 | collected.extend(t.foreign_keys) 694 | fks_back = [f for f in collected if f.other_table == table] 695 | is_view = table in view_names 696 | column_namer = Namer("c") 697 | table_metadata[table] = TableMetadata( 698 | columns=columns, 699 | foreign_keys=foreign_keys, 700 | fks_back=fks_back, 701 | pks=pks, 702 | supports_fts=supports_fts, 703 | is_view=is_view, 704 | graphql_name=table_namer.name(table), 705 | graphql_columns={column: column_namer.name(column) for column in columns}, 706 | ) 707 | 708 | return table_metadata 709 | 710 | 711 | def resolve_generic(root, info): 712 | json_string = getattr(root, info.field_name, "") 713 | return json.loads(json_string) 714 | 715 | 716 | _invalid_chars_re = re.compile(r"[^_a-zA-Z0-9]") 717 | 718 | 719 | class Namer: 720 | def __init__(self, underscore_prefix=""): 721 | # Disallow all Python keywords 722 | # https://github.com/simonw/datasette-graphql/issues/84 723 | self.reserved = set(keyword.kwlist) 724 | # 'description' confuses Graphene 725 | # https://github.com/simonw/datasette-graphql/issues/85 726 | self.reserved.add("description") 727 | # Track names we have already issued: 728 | self.names = set() 729 | self.underscore_prefix = underscore_prefix 730 | 731 | def name(self, value): 732 | value = "_".join(value.split()) 733 | value = _invalid_chars_re.sub("_", value) 734 | if not value: 735 | value = "_" 736 | if value[0].isdigit(): 737 | value = "_" + value 738 | if value in self.reserved: 739 | value += "_" 740 | if value.startswith("__"): 741 | value = "_0_" + value[2:] 742 | suffix = 2 743 | orig = value 744 | if value.startswith("_") and value.endswith("_"): 745 | value = self.underscore_prefix + value 746 | while value in self.names: 747 | value = orig + "_" + str(suffix) 748 | suffix += 1 749 | self.names.add(value) 750 | return value 751 | -------------------------------------------------------------------------------- /examples/base64.md: -------------------------------------------------------------------------------- 1 | # Binary content in base64 2 | 3 | ```graphql 4 | { 5 | _1_images { 6 | nodes { 7 | path 8 | content 9 | } 10 | } 11 | } 12 | ``` 13 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql/fixtures?query=%0A%7B%0A%20%20%20%20_1_images%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20path%0A%20%20%20%20%20%20%20%20%20%20%20%20content%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%7D%0A) 14 | 15 | Expected output: 16 | 17 | ```json 18 | { 19 | "_1_images": { 20 | "nodes": [ 21 | { 22 | "path": "1x1.gif", 23 | "content": "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" 24 | } 25 | ] 26 | } 27 | } 28 | ``` 29 | -------------------------------------------------------------------------------- /examples/basic.md: -------------------------------------------------------------------------------- 1 | # Basic table query 2 | 3 | ```graphql 4 | { 5 | users { 6 | nodes { 7 | name 8 | points 9 | score 10 | } 11 | } 12 | } 13 | ``` 14 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql/fixtures?query=%0A%7B%0A%20%20%20%20users%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20points%0A%20%20%20%20%20%20%20%20%20%20%20%20score%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%7D%0A) 15 | 16 | Expected output: 17 | 18 | ```json 19 | { 20 | "users": { 21 | "nodes": [ 22 | { 23 | "name": "cleopaws", 24 | "points": 5, 25 | "score": 51.5 26 | }, 27 | { 28 | "name": "simonw", 29 | "points": 3, 30 | "score": 35.2 31 | } 32 | ] 33 | } 34 | } 35 | ``` 36 | -------------------------------------------------------------------------------- /examples/filters.md: -------------------------------------------------------------------------------- 1 | # Table filters 2 | 3 | ```graphql 4 | { 5 | users_eq: users(filter: {name: {eq: "cleopaws"}}) { 6 | nodes { 7 | name 8 | } 9 | } 10 | users_not: users(filter: {name: {not: "cleopaws"}}) { 11 | nodes { 12 | name 13 | } 14 | } 15 | users_contains: users(filter: {name: {contains: "leo"}}) { 16 | nodes { 17 | name 18 | } 19 | } 20 | users_endswith: users(filter: {name: {endswith: "paws"}}) { 21 | nodes { 22 | name 23 | } 24 | } 25 | users_startswith: users(filter: {name: {startswith: "si"}}) { 26 | nodes { 27 | name 28 | } 29 | } 30 | users_gt: users(filter: {score: {gt: 50}}) { 31 | nodes { 32 | name 33 | score 34 | } 35 | } 36 | users_gte: users(filter: {score: {gte: 50}}) { 37 | nodes { 38 | name 39 | score 40 | } 41 | } 42 | users_lt: users(filter: {score: {lt: 50}}) { 43 | nodes { 44 | name 45 | score 46 | } 47 | } 48 | users_lte: users(filter: {score: {lte: 50}}) { 49 | nodes { 50 | name 51 | score 52 | } 53 | } 54 | users_like: users(filter: {name: {like: "cl%"}}) { 55 | nodes { 56 | name 57 | } 58 | } 59 | users_notlike: users(filter: {name: {notlike: "cl%"}}) { 60 | nodes { 61 | name 62 | } 63 | } 64 | users_glob: users(filter: {name: {glob: "cl*"}}) { 65 | nodes { 66 | name 67 | } 68 | } 69 | users_in: users(filter: {name: {in: ["cleopaws"]}}) { 70 | nodes { 71 | name 72 | } 73 | } 74 | users_in_integers: users(filter: {id: {in: [1, 2]}}) { 75 | nodes { 76 | id 77 | name 78 | } 79 | } 80 | users_notin: users(filter: {name: {notin: ["cleopaws"]}}) { 81 | nodes { 82 | name 83 | } 84 | } 85 | users_notin_integers: users(filter: {id: {notin: [2]}}) { 86 | nodes { 87 | id 88 | name 89 | } 90 | } 91 | repos_arraycontains: repos(filter: {tags: {arraycontains: "dogs"}}) { 92 | nodes { 93 | full_name 94 | tags 95 | } 96 | } 97 | users_date: users(filter: {joined: {date: "2018-11-04"}}) { 98 | nodes { 99 | name 100 | } 101 | } 102 | users_isnull: users(filter: {dog_award: {isnull: true}}) { 103 | nodes { 104 | name 105 | dog_award 106 | } 107 | } 108 | users_notnull: users(filter: {dog_award: {notnull: true}}) { 109 | nodes { 110 | name 111 | dog_award 112 | } 113 | } 114 | users_isblank: users(filter: {dog_award: {isblank: true}}) { 115 | nodes { 116 | name 117 | dog_award 118 | } 119 | } 120 | users_notblank: users(filter: {dog_award: {notblank: true}}) { 121 | nodes { 122 | name 123 | dog_award 124 | } 125 | } 126 | } 127 | ``` 128 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql/fixtures?query=%0A%7B%0A%20%20%20%20users_eq%3A%20users%28filter%3A%20%7Bname%3A%20%7Beq%3A%20%22cleopaws%22%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_not%3A%20users%28filter%3A%20%7Bname%3A%20%7Bnot%3A%20%22cleopaws%22%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_contains%3A%20users%28filter%3A%20%7Bname%3A%20%7Bcontains%3A%20%22leo%22%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_endswith%3A%20users%28filter%3A%20%7Bname%3A%20%7Bendswith%3A%20%22paws%22%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_startswith%3A%20users%28filter%3A%20%7Bname%3A%20%7Bstartswith%3A%20%22si%22%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_gt%3A%20users%28filter%3A%20%7Bscore%3A%20%7Bgt%3A%2050%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20score%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_gte%3A%20users%28filter%3A%20%7Bscore%3A%20%7Bgte%3A%2050%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20score%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_lt%3A%20users%28filter%3A%20%7Bscore%3A%20%7Blt%3A%2050%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20score%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_lte%3A%20users%28filter%3A%20%7Bscore%3A%20%7Blte%3A%2050%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20score%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_like%3A%20users%28filter%3A%20%7Bname%3A%20%7Blike%3A%20%22cl%25%22%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_notlike%3A%20users%28filter%3A%20%7Bname%3A%20%7Bnotlike%3A%20%22cl%25%22%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_glob%3A%20users%28filter%3A%20%7Bname%3A%20%7Bglob%3A%20%22cl%2A%22%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_in%3A%20users%28filter%3A%20%7Bname%3A%20%7Bin%3A%20%5B%22cleopaws%22%5D%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_in_integers%3A%20users%28filter%3A%20%7Bid%3A%20%7Bin%3A%20%5B1%2C%202%5D%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20id%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_notin%3A%20users%28filter%3A%20%7Bname%3A%20%7Bnotin%3A%20%5B%22cleopaws%22%5D%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_notin_integers%3A%20users%28filter%3A%20%7Bid%3A%20%7Bnotin%3A%20%5B2%5D%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20id%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20repos_arraycontains%3A%20repos%28filter%3A%20%7Btags%3A%20%7Barraycontains%3A%20%22dogs%22%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20full_name%0A%20%20%20%20%20%20%20%20%20%20%20%20tags%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_date%3A%20users%28filter%3A%20%7Bjoined%3A%20%7Bdate%3A%20%222018-11-04%22%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_isnull%3A%20users%28filter%3A%20%7Bdog_award%3A%20%7Bisnull%3A%20true%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20dog_award%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_notnull%3A%20users%28filter%3A%20%7Bdog_award%3A%20%7Bnotnull%3A%20true%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20dog_award%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_isblank%3A%20users%28filter%3A%20%7Bdog_award%3A%20%7Bisblank%3A%20true%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20dog_award%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_notblank%3A%20users%28filter%3A%20%7Bdog_award%3A%20%7Bnotblank%3A%20true%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20dog_award%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%7D%0A) 129 | 130 | 131 | Expected output: 132 | 133 | ```json 134 | { 135 | "users_eq": { 136 | "nodes": [ 137 | { 138 | "name": "cleopaws" 139 | } 140 | ] 141 | }, 142 | "users_not": { 143 | "nodes": [ 144 | { 145 | "name": "simonw" 146 | } 147 | ] 148 | }, 149 | "users_contains": { 150 | "nodes": [ 151 | { 152 | "name": "cleopaws" 153 | } 154 | ] 155 | }, 156 | "users_endswith": { 157 | "nodes": [ 158 | { 159 | "name": "cleopaws" 160 | } 161 | ] 162 | }, 163 | "users_startswith": { 164 | "nodes": [ 165 | { 166 | "name": "simonw" 167 | } 168 | ] 169 | }, 170 | "users_gt": { 171 | "nodes": [ 172 | { 173 | "name": "cleopaws", 174 | "score": 51.5 175 | } 176 | ] 177 | }, 178 | "users_gte": { 179 | "nodes": [ 180 | { 181 | "name": "cleopaws", 182 | "score": 51.5 183 | } 184 | ] 185 | }, 186 | "users_lt": { 187 | "nodes": [ 188 | { 189 | "name": "simonw", 190 | "score": 35.2 191 | } 192 | ] 193 | }, 194 | "users_lte": { 195 | "nodes": [ 196 | { 197 | "name": "simonw", 198 | "score": 35.2 199 | } 200 | ] 201 | }, 202 | "users_like": { 203 | "nodes": [ 204 | { 205 | "name": "cleopaws" 206 | } 207 | ] 208 | }, 209 | "users_notlike": { 210 | "nodes": [ 211 | { 212 | "name": "simonw" 213 | } 214 | ] 215 | }, 216 | "users_glob": { 217 | "nodes": [ 218 | { 219 | "name": "cleopaws" 220 | } 221 | ] 222 | }, 223 | "users_in": { 224 | "nodes": [ 225 | { 226 | "name": "cleopaws" 227 | } 228 | ] 229 | }, 230 | "users_in_integers": { 231 | "nodes": [ 232 | { 233 | "id": 1, 234 | "name": "cleopaws" 235 | }, 236 | { 237 | "id": 2, 238 | "name": "simonw" 239 | } 240 | ] 241 | }, 242 | "users_notin": { 243 | "nodes": [ 244 | { 245 | "name": "simonw" 246 | } 247 | ] 248 | }, 249 | "users_notin_integers": { 250 | "nodes": [ 251 | { 252 | "id": 1, 253 | "name": "cleopaws" 254 | } 255 | ] 256 | }, 257 | "repos_arraycontains": { 258 | "nodes": [ 259 | { 260 | "full_name": "cleopaws/dogspotter", 261 | "tags": "[\"dogs\"]" 262 | } 263 | ] 264 | }, 265 | "users_date": { 266 | "nodes": [ 267 | { 268 | "name": "cleopaws" 269 | } 270 | ] 271 | }, 272 | "users_isnull": { 273 | "nodes": [ 274 | { 275 | "name": "simonw", 276 | "dog_award": null 277 | } 278 | ] 279 | }, 280 | "users_notnull": { 281 | "nodes": [ 282 | { 283 | "name": "cleopaws", 284 | "dog_award": "3rd best mutt" 285 | } 286 | ] 287 | }, 288 | "users_isblank": { 289 | "nodes": [ 290 | { 291 | "name": "simonw", 292 | "dog_award": null 293 | } 294 | ] 295 | }, 296 | "users_notblank": { 297 | "nodes": [ 298 | { 299 | "name": "cleopaws", 300 | "dog_award": "3rd best mutt" 301 | } 302 | ] 303 | } 304 | } 305 | ``` 306 | -------------------------------------------------------------------------------- /examples/fragments.md: -------------------------------------------------------------------------------- 1 | # Fragments 2 | 3 | ```graphql 4 | { 5 | cleopaws: users_row(id: 1) { 6 | ...userFields 7 | } 8 | simonw: users_row(id: 2) { 9 | ...userFields 10 | } 11 | } 12 | 13 | fragment userFields on users { 14 | id 15 | name 16 | dog_award 17 | } 18 | ``` 19 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql/fixtures?query=%0A%7B%0A%20%20cleopaws%3A%20users_row%28id%3A%201%29%20%7B%0A%20%20%20%20...userFields%0A%20%20%7D%0A%20%20simonw%3A%20users_row%28id%3A%202%29%20%7B%0A%20%20%20%20...userFields%0A%20%20%7D%0A%7D%0A%0Afragment%20userFields%20on%20users%20%7B%0A%20%20id%0A%20%20name%0A%20%20dog_award%0A%7D%0A) 20 | 21 | Expected output: 22 | 23 | ```json 24 | { 25 | "cleopaws": { 26 | "id": 1, 27 | "name": "cleopaws", 28 | "dog_award": "3rd best mutt" 29 | }, 30 | "simonw": { 31 | "id": 2, 32 | "name": "simonw", 33 | "dog_award": null 34 | } 35 | } 36 | ``` 37 | -------------------------------------------------------------------------------- /examples/nested.md: -------------------------------------------------------------------------------- 1 | # Nested query 2 | 3 | ```graphql 4 | { 5 | issues { 6 | nodes { 7 | title 8 | user { 9 | id 10 | name 11 | } 12 | repo { 13 | name 14 | license { 15 | _key 16 | name 17 | } 18 | owner { 19 | id 20 | name 21 | } 22 | } 23 | } 24 | } 25 | } 26 | ``` 27 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql/fixtures?query=%0A%7B%0A%20%20%20%20issues%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20title%0A%20%20%20%20%20%20%20%20%20%20%20%20user%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20id%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20repo%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20license%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20_key%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20owner%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20id%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%7D%0A) 28 | 29 | Expected output: 30 | 31 | ```json 32 | { 33 | "issues": { 34 | "nodes": [ 35 | { 36 | "title": "Not enough dog stuff", 37 | "user": { 38 | "id": 1, 39 | "name": "cleopaws" 40 | }, 41 | "repo": { 42 | "name": "datasette", 43 | "license": { 44 | "_key": "apache2", 45 | "name": "Apache 2" 46 | }, 47 | "owner": { 48 | "id": 2, 49 | "name": "simonw" 50 | } 51 | } 52 | } 53 | ] 54 | } 55 | } 56 | ``` 57 | -------------------------------------------------------------------------------- /examples/nullable.md: -------------------------------------------------------------------------------- 1 | # Nested query with foreign keys 2 | 3 | ```graphql 4 | { 5 | repos { 6 | nodes { 7 | name 8 | license { 9 | _key 10 | name 11 | } 12 | } 13 | } 14 | } 15 | ``` 16 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql/fixtures?query=%0A%7B%0A%20%20%20%20repos%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20license%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20_key%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%7D%0A) 17 | 18 | Expected output: 19 | 20 | ```json 21 | { 22 | "repos": { 23 | "nodes": [ 24 | { 25 | "name": "datasette", 26 | "license": { 27 | "_key": "apache2", 28 | "name": "Apache 2" 29 | } 30 | }, 31 | { 32 | "name": "dogspotter", 33 | "license": { 34 | "_key": "mit", 35 | "name": "MIT" 36 | } 37 | }, 38 | { 39 | "name": "private", 40 | "license": null 41 | } 42 | ] 43 | } 44 | } 45 | ``` 46 | -------------------------------------------------------------------------------- /examples/related.md: -------------------------------------------------------------------------------- 1 | # Fetch related rows 2 | 3 | ```graphql 4 | { 5 | users { 6 | nodes { 7 | name 8 | repos_list(first: 1) { 9 | totalCount 10 | pageInfo { 11 | endCursor 12 | hasNextPage 13 | } 14 | nodes { 15 | name 16 | license { 17 | _key 18 | name 19 | } 20 | } 21 | } 22 | } 23 | } 24 | users_with_dogspotter: users { 25 | nodes { 26 | name 27 | repos_list(filter: {name: {eq: "dogspotter"}}, sort: id) { 28 | totalCount 29 | pageInfo { 30 | endCursor 31 | hasNextPage 32 | } 33 | nodes { 34 | name 35 | } 36 | } 37 | } 38 | } 39 | licenses_with_repos: licenses { 40 | nodes { 41 | name 42 | repos_list { 43 | nodes { 44 | full_name 45 | } 46 | } 47 | } 48 | } 49 | } 50 | ``` 51 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql/fixtures?query=%0A%7B%0A%20%20%20%20users%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20repos_list%28first%3A%201%29%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20totalCount%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20pageInfo%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20endCursor%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20hasNextPage%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20license%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20_key%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_with_dogspotter%3A%20users%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20repos_list%28filter%3A%20%7Bname%3A%20%7Beq%3A%20%22dogspotter%22%7D%7D%2C%20sort%3A%20id%29%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20totalCount%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20pageInfo%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20endCursor%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20hasNextPage%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20licenses_with_repos%3A%20licenses%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20repos_list%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20full_name%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%7D%0A) 52 | 53 | Expected output: 54 | 55 | ```json 56 | { 57 | "users": { 58 | "nodes": [ 59 | { 60 | "name": "cleopaws", 61 | "repos_list": { 62 | "totalCount": 1, 63 | "pageInfo": { 64 | "endCursor": null, 65 | "hasNextPage": false 66 | }, 67 | "nodes": [ 68 | { 69 | "name": "dogspotter", 70 | "license": { 71 | "_key": "mit", 72 | "name": "MIT" 73 | } 74 | } 75 | ] 76 | } 77 | }, 78 | { 79 | "name": "simonw", 80 | "repos_list": { 81 | "totalCount": 2, 82 | "pageInfo": { 83 | "endCursor": "1", 84 | "hasNextPage": true 85 | }, 86 | "nodes": [ 87 | { 88 | "name": "datasette", 89 | "license": { 90 | "_key": "apache2", 91 | "name": "Apache 2" 92 | } 93 | } 94 | ] 95 | } 96 | } 97 | ] 98 | }, 99 | "users_with_dogspotter": { 100 | "nodes": [ 101 | { 102 | "name": "cleopaws", 103 | "repos_list": { 104 | "totalCount": 1, 105 | "pageInfo": { 106 | "endCursor": null, 107 | "hasNextPage": false 108 | }, 109 | "nodes": [ 110 | { 111 | "name": "dogspotter" 112 | } 113 | ] 114 | } 115 | }, 116 | { 117 | "name": "simonw", 118 | "repos_list": { 119 | "totalCount": 0, 120 | "pageInfo": { 121 | "endCursor": null, 122 | "hasNextPage": false 123 | }, 124 | "nodes": [] 125 | } 126 | } 127 | ] 128 | }, 129 | "licenses_with_repos": { 130 | "nodes": [ 131 | { 132 | "name": "Apache 2", 133 | "repos_list": { 134 | "nodes": [ 135 | { 136 | "full_name": "simonw/datasette" 137 | } 138 | ] 139 | } 140 | }, 141 | { 142 | "name": "MIT", 143 | "repos_list": { 144 | "nodes": [ 145 | { 146 | "full_name": "cleopaws/dogspotter" 147 | } 148 | ] 149 | } 150 | } 151 | ] 152 | } 153 | } 154 | ``` 155 | -------------------------------------------------------------------------------- /examples/related_multiple.md: -------------------------------------------------------------------------------- 1 | # Related rows for tables with multiple foreign keys 2 | 3 | If another table has multiple foreign keys back to the same table, related row fields are created to avoid name clashes: 4 | 5 | ```graphql 6 | { 7 | users { 8 | nodes { 9 | id 10 | name 11 | issues_by_user_list { 12 | nodes { 13 | id 14 | title 15 | updated_by { 16 | name 17 | } 18 | user { 19 | name 20 | } 21 | } 22 | } 23 | issues_by_updated_by_list { 24 | nodes { 25 | id 26 | title 27 | updated_by { 28 | name 29 | } 30 | user { 31 | name 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | ``` 39 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql/fixtures?query=%0A%7B%0A%20%20users%20%7B%0A%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20id%0A%20%20%20%20%20%20name%0A%20%20%20%20%20%20issues_by_user_list%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20id%0A%20%20%20%20%20%20%20%20%20%20title%0A%20%20%20%20%20%20%20%20%20%20updated_by%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20user%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20issues_by_updated_by_list%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20id%0A%20%20%20%20%20%20%20%20%20%20title%0A%20%20%20%20%20%20%20%20%20%20updated_by%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20user%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A) 40 | 41 | Expected output: 42 | 43 | ```json 44 | { 45 | "users": { 46 | "nodes": [ 47 | { 48 | "id": 1, 49 | "name": "cleopaws", 50 | "issues_by_user_list": { 51 | "nodes": [ 52 | { 53 | "id": 111, 54 | "title": "Not enough dog stuff", 55 | "updated_by": { 56 | "name": "simonw" 57 | }, 58 | "user": { 59 | "name": "cleopaws" 60 | } 61 | } 62 | ] 63 | }, 64 | "issues_by_updated_by_list": { 65 | "nodes": [] 66 | } 67 | }, 68 | { 69 | "id": 2, 70 | "name": "simonw", 71 | "issues_by_user_list": { 72 | "nodes": [] 73 | }, 74 | "issues_by_updated_by_list": { 75 | "nodes": [ 76 | { 77 | "id": 111, 78 | "title": "Not enough dog stuff", 79 | "updated_by": { 80 | "name": "simonw" 81 | }, 82 | "user": { 83 | "name": "cleopaws" 84 | } 85 | } 86 | ] 87 | } 88 | } 89 | ] 90 | } 91 | } 92 | ``` 93 | -------------------------------------------------------------------------------- /examples/search.md: -------------------------------------------------------------------------------- 1 | # Table search 2 | 3 | ```graphql 4 | { 5 | repos(search: "cleopaws") { 6 | nodes { 7 | full_name 8 | } 9 | } 10 | users_row(id: 1) { 11 | name 12 | repos_list(search:"dogspotter") { 13 | nodes { 14 | full_name 15 | } 16 | } 17 | } 18 | } 19 | ``` 20 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql/fixtures?query=%0A%7B%0A%20%20%20%20repos%28search%3A%20%22cleopaws%22%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20full_name%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_row%28id%3A%201%29%20%7B%0A%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20repos_list%28search%3A%22dogspotter%22%29%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20full_name%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%7D%0A) 21 | 22 | Expected output: 23 | 24 | ```json 25 | { 26 | "repos": { 27 | "nodes": [ 28 | { 29 | "full_name": "cleopaws/dogspotter" 30 | } 31 | ] 32 | }, 33 | "users_row": { 34 | "name": "cleopaws", 35 | "repos_list": { 36 | "nodes": [ 37 | { 38 | "full_name": "cleopaws/dogspotter" 39 | } 40 | ] 41 | } 42 | } 43 | } 44 | ``` 45 | -------------------------------------------------------------------------------- /examples/sort.md: -------------------------------------------------------------------------------- 1 | # Table search 2 | 3 | ```graphql 4 | { 5 | users(sort: points) { 6 | nodes { 7 | name 8 | points 9 | } 10 | } 11 | users_sort_by_dog_award: users(sort: dog_award) { 12 | nodes { 13 | name 14 | dog_award 15 | } 16 | } 17 | } 18 | ``` 19 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql/fixtures?query=%0A%7B%0A%20%20%20%20users%28sort%3A%20points%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20points%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%20%20users_sort_by_dog_award%3A%20users%28sort%3A%20dog_award%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20dog_award%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%7D%0A) 20 | 21 | Expected output: 22 | 23 | ```json 24 | { 25 | "users": { 26 | "nodes": [ 27 | { 28 | "name": "simonw", 29 | "points": 3 30 | }, 31 | { 32 | "name": "cleopaws", 33 | "points": 5 34 | } 35 | ] 36 | }, 37 | "users_sort_by_dog_award": { 38 | "nodes": [ 39 | { 40 | "name": "simonw", 41 | "dog_award": null 42 | }, 43 | { 44 | "name": "cleopaws", 45 | "dog_award": "3rd best mutt" 46 | } 47 | ] 48 | } 49 | } 50 | ``` 51 | -------------------------------------------------------------------------------- /examples/sort_desc.md: -------------------------------------------------------------------------------- 1 | # Table search 2 | 3 | ```graphql 4 | { 5 | users(sort_desc: points) { 6 | nodes { 7 | name 8 | points 9 | } 10 | } 11 | } 12 | ``` 13 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql/fixtures?query=%0A%7B%0A%20%20%20%20users%28sort_desc%3A%20points%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20points%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%7D%0A) 14 | 15 | Expected output: 16 | 17 | ```json 18 | { 19 | "users": { 20 | "nodes": [ 21 | { 22 | "name": "cleopaws", 23 | "points": 5 24 | }, 25 | { 26 | "name": "simonw", 27 | "points": 3 28 | } 29 | ] 30 | } 31 | } 32 | ``` 33 | -------------------------------------------------------------------------------- /examples/sql_view.md: -------------------------------------------------------------------------------- 1 | # SQL view 2 | 3 | ```graphql 4 | { 5 | view_on_table_with_pk(first: 3) { 6 | nodes { 7 | pk 8 | name 9 | } 10 | } 11 | } 12 | ``` 13 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql/fixtures?query=%0A%7B%0A%20%20%20%20view_on_table_with_pk%28first%3A%203%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20pk%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%7D%0A) 14 | 15 | Expected output: 16 | 17 | ```json 18 | { 19 | "view_on_table_with_pk": { 20 | "nodes": [ 21 | { 22 | "pk": 1, 23 | "name": "Row 1" 24 | }, 25 | { 26 | "pk": 2, 27 | "name": "Row 2" 28 | }, 29 | { 30 | "pk": 3, 31 | "name": "Row 3" 32 | } 33 | ] 34 | } 35 | } 36 | ``` 37 | -------------------------------------------------------------------------------- /examples/table_row.md: -------------------------------------------------------------------------------- 1 | # table_row to retrieve individual rows 2 | 3 | Every table in the database automatically gets a field that is the name of the table plus `_row`. This field can be used to directly retrieve individual rows. The primary key columns of the table become field arguments. `rowid` and compound primary key tables are also supported. 4 | 5 | ```graphql 6 | { 7 | table_with_rowid_row(rowid: 1) { 8 | name 9 | } 10 | table_with_pk_row(pk: 1) { 11 | pk 12 | name 13 | } 14 | table_with_compound_pk_row(pk1:1, pk2:3) { 15 | name 16 | pk1 17 | pk2 18 | } 19 | users_row(id: 12345) { 20 | name 21 | } 22 | } 23 | ``` 24 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql/fixtures?query=%0A%7B%0A%20%20table_with_rowid_row%28rowid%3A%201%29%20%7B%0A%20%20%20%20name%0A%20%20%7D%0A%20%20table_with_pk_row%28pk%3A%201%29%20%7B%0A%20%20%20%20pk%0A%20%20%20%20name%0A%20%20%7D%0A%20%20table_with_compound_pk_row%28pk1%3A1%2C%20pk2%3A3%29%20%7B%0A%20%20%20%20name%0A%20%20%20%20pk1%0A%20%20%20%20pk2%0A%20%20%7D%0A%20%20users_row%28id%3A%2012345%29%20%7B%0A%20%20%20%20name%0A%20%20%7D%0A%7D%0A) 25 | 26 | ```json 27 | { 28 | "table_with_rowid_row": { 29 | "name": "Row 1" 30 | }, 31 | "table_with_pk_row": { 32 | "pk": 1, 33 | "name": "Row 1" 34 | }, 35 | "table_with_compound_pk_row": { 36 | "name": "Row 1 3", 37 | "pk1": 1, 38 | "pk2": 3 39 | }, 40 | "users_row": null 41 | } 42 | ``` 43 | That last `users_row` result is `null` because the provided ID refers to a record that does not exist. 44 | -------------------------------------------------------------------------------- /examples/variables.md: -------------------------------------------------------------------------------- 1 | # GraphQL variables 2 | 3 | [GraphQL variables](https://graphql.org/learn/queries/#variables) can be incorporated into queries. 4 | 5 | ```graphql 6 | query ($name: String) { 7 | repos(filter: {name: {eq: $name}}) { 8 | nodes { 9 | name 10 | license { 11 | _key 12 | } 13 | } 14 | } 15 | } 16 | ``` 17 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql/fixtures?query=%0Aquery%20%28%24name%3A%20String%29%20%7B%0A%20%20%20%20repos%28filter%3A%20%7Bname%3A%20%7Beq%3A%20%24name%7D%7D%29%20%7B%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20license%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20_key%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%7D%0A&variables=%7B%0A%20%20%20%20%22name%22%3A%20%22datasette%22%0A%7D%0A) 18 | 19 | Variables: 20 | ```json+variables 21 | { 22 | "name": "datasette" 23 | } 24 | ``` 25 | Expected output: 26 | ```json 27 | { 28 | "repos": { 29 | "nodes": [ 30 | { 31 | "name": "datasette", 32 | "license": { 33 | "_key": "apache2" 34 | } 35 | } 36 | ] 37 | } 38 | } 39 | ``` 40 | -------------------------------------------------------------------------------- /examples/where.md: -------------------------------------------------------------------------------- 1 | # The where: argument 2 | 3 | The `where:` argument can be used as an alternative to `filter:` when the thing you are expressing is too complex to be modeled using a filter expression. It accepts a string fragment of SQL that will be included in the `WHERE` clause of the SQL query. 4 | 5 | ```graphql 6 | { 7 | repos(where: "name='dogspotter' or license is null") { 8 | totalCount 9 | nodes { 10 | full_name 11 | } 12 | } 13 | } 14 | ``` 15 | [Try this query](https://datasette-graphql-demo.datasette.io/graphql/fixtures?query=%0A%7B%0A%20%20%20%20repos%28where%3A%20%22name%3D%27dogspotter%27%20or%20license%20is%20null%22%29%20%7B%0A%20%20%20%20%20%20%20%20totalCount%0A%20%20%20%20%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20full_name%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%7D%0A) 16 | 17 | Expected output: 18 | 19 | ```json 20 | { 21 | "repos": { 22 | "totalCount": 2, 23 | "nodes": [ 24 | { 25 | "full_name": "cleopaws/dogspotter" 26 | }, 27 | { 28 | "full_name": "simonw/private" 29 | } 30 | ] 31 | } 32 | } 33 | ``` 34 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | asyncio_mode = strict 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import os 3 | 4 | VERSION = "2.2" 5 | 6 | 7 | def get_long_description(): 8 | with open( 9 | os.path.join(os.path.dirname(os.path.abspath(__file__)), "README.md"), 10 | encoding="utf8", 11 | ) as fp: 12 | return fp.read() 13 | 14 | 15 | setup( 16 | name="datasette-graphql", 17 | description="Datasette plugin providing an automatic GraphQL API for your SQLite databases", 18 | long_description=get_long_description(), 19 | long_description_content_type="text/markdown", 20 | author="Simon Willison", 21 | url="https://github.com/simonw/datasette-graphql", 22 | project_urls={ 23 | "Issues": "https://github.com/simonw/datasette-graphql/issues", 24 | "CI": "https://github.com/simonw/datasette-graphql/actions", 25 | "Changelog": "https://github.com/simonw/datasette-graphql/releases", 26 | }, 27 | license="Apache License, Version 2.0", 28 | version=VERSION, 29 | packages=["datasette_graphql"], 30 | entry_points={"datasette": ["graphql = datasette_graphql"]}, 31 | install_requires=[ 32 | "datasette>=0.58.1", 33 | "graphene>=3.1.0,<4.0", 34 | "graphql-core>=3.2.1", 35 | "sqlite-utils", 36 | ], 37 | extras_require={"test": ["pytest", "pytest-asyncio"]}, 38 | package_data={ 39 | "datasette_graphql": ["templates/*.html", "static/*.js", "static/*.css"] 40 | }, 41 | ) 42 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonw/datasette-graphql/7e8914207b07d5be7ba25d607fff330a173b9fbc/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | def pytest_addoption(parser): 2 | parser.addoption( 3 | "--rewrite-examples", 4 | action="store_true", 5 | default=False, 6 | help="Rewrite examples/ 'Try this query' links on error", 7 | ) 8 | -------------------------------------------------------------------------------- /tests/fixtures.py: -------------------------------------------------------------------------------- 1 | from datasette.app import Datasette 2 | import pytest 3 | import sqlite_utils 4 | 5 | GIF_1x1 = b"GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff!\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x01D\x00;" 6 | 7 | 8 | def build_database(db): 9 | db["users"].insert_all( 10 | [ 11 | { 12 | "id": 1, 13 | "name": "cleopaws", 14 | "points": 5, 15 | "score": 51.5, 16 | "joined": "2018-11-04 00:05:23", 17 | "dog award": "3rd best mutt", 18 | }, 19 | { 20 | "id": 2, 21 | "name": "simonw", 22 | "points": 3, 23 | "score": 35.2, 24 | "joined": "2019-04-03 12:35:11", 25 | "dog award": None, 26 | }, 27 | ], 28 | pk="id", 29 | ) 30 | db["licenses"].insert_all( 31 | [ 32 | {"$key": "mit", "name": "MIT"}, 33 | {"$key": "apache2", "name": "Apache 2"}, 34 | ], 35 | pk="$key", 36 | ) 37 | db["type_compound_key"].insert_all( 38 | [{"type": "possum", "id": 1, "name": "Fairway Frank"}], pk=("type", "id") 39 | ) 40 | db["repos"].insert_all( 41 | [ 42 | { 43 | "id": 1, 44 | "full_name": "simonw/datasette", 45 | "name": "datasette", 46 | "owner": 2, 47 | "license": "apache2", 48 | "tags": ["databases", "apis"], 49 | }, 50 | { 51 | "id": 2, 52 | "full_name": "cleopaws/dogspotter", 53 | "name": "dogspotter", 54 | "owner": 1, 55 | "license": "mit", 56 | "tags": ["dogs"], 57 | }, 58 | { 59 | "id": 3, 60 | "full_name": "simonw/private", 61 | "name": "private", 62 | "owner": 2, 63 | "license": None, 64 | "tags": [], 65 | }, 66 | ], 67 | pk="id", 68 | foreign_keys=(("owner", "users"), ("license", "licenses")), 69 | ).enable_fts(["full_name"], fts_version="FTS4") 70 | db["issues"].insert_all( 71 | [ 72 | { 73 | "id": 111, 74 | "title": "Not enough dog stuff", 75 | "user": 1, 76 | "repo": 1, 77 | "updated_by": 2, 78 | } 79 | ], 80 | pk="id", 81 | foreign_keys=( 82 | ("user", "users", "id"), 83 | ("repo", "repos", "id"), 84 | ("updated_by", "users", "id"), 85 | ), 86 | ) 87 | db["1_images"].insert({"path": "1x1.gif", "content": GIF_1x1}, pk="path") 88 | # https://github.com/simonw/datasette-graphql/issues/48 89 | db["_table_"].insert({"_column_": 1}) 90 | # To test pagination with both rowid, single-pk and compound-pk tables: 91 | db["table_with_rowid"].insert_all( 92 | [{"name": "Row {}".format(i)} for i in range(1, 22)] 93 | ) 94 | db["table_with_pk"].insert_all( 95 | [{"pk": i, "name": "Row {}".format(i)} for i in range(1, 22)], pk="pk" 96 | ) 97 | db["table_with_compound_pk"].insert_all( 98 | [ 99 | {"pk1": i, "pk2": j, "name": "Row {} {}".format(i, j)} 100 | for i in range(1, 4) 101 | for j in range(1, 8) 102 | ], 103 | pk=("pk1", "pk2"), 104 | ) 105 | db["table_with_reserved_columns"].insert( 106 | {"id": 1, "if": "keyword if", "description": "a description"}, pk="id" 107 | ) 108 | db["table_with_dangerous_columns"].insert( 109 | {"id": 1, "# Starts With Hash": 2, "__double_underscore": 3} 110 | ) 111 | db.create_view("view_on_table_with_pk", "select * from table_with_pk") 112 | db.create_view("view_on_repos", "select * from repos") 113 | # Create a table with a non-existent foreign key 114 | db.execute( 115 | """ 116 | create table bad_foreign_key ( 117 | fk integer not null references not_a_table(id) deferrable initially deferred 118 | ); 119 | """ 120 | ) 121 | db["bad_foreign_key"].insert({"fk": 1}) 122 | 123 | 124 | @pytest.fixture(scope="session") 125 | def db_path(tmp_path_factory): 126 | db_directory = tmp_path_factory.mktemp("dbs") 127 | db_path = db_directory / "test.db" 128 | db = sqlite_utils.Database(db_path) 129 | build_database(db) 130 | return db_path 131 | 132 | 133 | @pytest.fixture(scope="session") 134 | def db_path2(tmp_path_factory): 135 | db_directory = tmp_path_factory.mktemp("dbs") 136 | db_path = db_directory / "test2.db" 137 | db = sqlite_utils.Database(db_path) 138 | db["test_table"].insert({"full_name": "This is a full name"}) 139 | return db_path 140 | 141 | 142 | @pytest.fixture(scope="session") 143 | def ds(db_path): 144 | return Datasette([str(db_path)], pdb=True) 145 | 146 | 147 | if __name__ == "__main__": 148 | import sys 149 | 150 | if not sys.argv[-1].endswith(".db"): 151 | print("Usage: python fixtures.py fixtures.db") 152 | sys.exit(1) 153 | 154 | db = sqlite_utils.Database(sys.argv[-1]) 155 | build_database(db) 156 | print("Data written to {}".format(sys.argv[-1])) 157 | -------------------------------------------------------------------------------- /tests/test_docs.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import pytest 3 | import re 4 | import urllib 5 | from .test_graphql import graphql_re, variables_re 6 | 7 | link_re = re.compile(r"\[Try this query]\((.*?)\)") 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "path", 12 | list((pathlib.Path(__file__).parent.parent / "examples").glob("*.md")) 13 | + [pathlib.Path(__file__).parent.parent / "README.md"], 14 | ) 15 | def test_examples_link_to_live_demo(request, path): 16 | should_rewrite = request.config.getoption("--rewrite-examples") 17 | is_readme = path.name == "README.md" 18 | content = path.read_text() 19 | ideal_content = content 20 | variables = None 21 | if not is_readme: 22 | try: 23 | variables = variables_re.search(content)[1] 24 | except TypeError: 25 | pass 26 | 27 | for match in graphql_re.finditer(content): 28 | query = match.group(1) 29 | if "packages {" in query: 30 | # Skip the example illustrating the packages plugin 31 | continue 32 | start = match.start() 33 | end = match.end() 34 | args = {"query": query} 35 | if variables: 36 | args["variables"] = variables 37 | expected_url = ( 38 | "https://datasette-graphql-demo.datasette.io/graphql{}?{}".format( 39 | "" if is_readme else "/fixtures", 40 | urllib.parse.urlencode(args, quote_via=urllib.parse.quote), 41 | ) 42 | ) 43 | fixed_fragment = "```graphql\n{}\n```\n[Try this query]({})\n".format( 44 | query.strip(), expected_url 45 | ) 46 | # Check for the `[Try this query ...]` that follows this one character later 47 | try_this_match = link_re.search(content, start) 48 | if try_this_match is None or try_this_match.start() - end != 1: 49 | if should_rewrite: 50 | query_fix_re = re.compile( 51 | r"```graphql\n{}\n```\n".format(re.escape(query.strip())) 52 | ) 53 | ideal_content = query_fix_re.sub(fixed_fragment, ideal_content) 54 | else: 55 | assert ( 56 | False 57 | ), "{}: [Try this query] link should follow {}\n\nFix with pytest --rewrite-examples'".format( 58 | path, query 59 | ) 60 | else: 61 | # The link is there! But does it have the correct URL? 62 | if expected_url != try_this_match.group(1): 63 | if should_rewrite: 64 | query_link_fix_re = re.compile( 65 | r"```graphql\n{}\n```\n\[Try this query]\((.*?)\)".format( 66 | re.escape(query.strip()) 67 | ) 68 | ) 69 | ideal_content = query_link_fix_re.sub(fixed_fragment, ideal_content) 70 | else: 71 | assert ( 72 | False 73 | ), "{}: Expected URL {} for {}\n\nFix with pytest --rewrite-examples'".format( 74 | path, expected_url, query 75 | ) 76 | 77 | if should_rewrite and ideal_content != content: 78 | path.write_text(ideal_content) 79 | -------------------------------------------------------------------------------- /tests/test_graphql.py: -------------------------------------------------------------------------------- 1 | from datasette.app import Datasette 2 | from datasette_graphql.utils import _schema_cache 3 | import json 4 | import pathlib 5 | import pytest 6 | import re 7 | import urllib 8 | import textwrap 9 | from .fixtures import ds, db_path, db_path2 10 | 11 | graphql_re = re.compile(r"```graphql(.*?)```", re.DOTALL) 12 | json_re = re.compile(r"```json\n(.*?)```", re.DOTALL) 13 | variables_re = re.compile(r"```json\+variables\n(.*?)```", re.DOTALL) 14 | whitespace_re = re.compile(r"\s+") 15 | 16 | 17 | def equal_no_whitespace(s1, s2): 18 | return whitespace_re.sub("", s1) == whitespace_re.sub("", s2) 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_plugin_is_installed(): 23 | ds = Datasette([], memory=True) 24 | response = await ds.client.get("/-/plugins.json") 25 | assert 200 == response.status_code 26 | installed_plugins = {p["name"] for p in response.json()} 27 | assert "datasette-graphql" in installed_plugins 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_menu(): 32 | ds = Datasette([], memory=True) 33 | response = await ds.client.get("/") 34 | assert 200 == response.status_code 35 | assert '
  • GraphQL API
  • ' in response.text 36 | 37 | 38 | @pytest.mark.asyncio 39 | async def test_graphiql(): 40 | ds = Datasette([], memory=True) 41 | response = await ds.client.get("/graphql", headers={"Accept": "text/html"}) 42 | assert response.status_code == 200 43 | assert "GraphiQL" in response.text 44 | # Check that bundled assets are all present 45 | paths = [] 46 | paths.extend(re.findall(r' 0.1ms - /test/repos.json?_nofacet=1&_size=10&_search=dogspotter" 541 | ) 542 | 543 | 544 | @pytest.mark.asyncio 545 | async def test_num_queries_limit(db_path): 546 | ds = Datasette( 547 | [str(db_path)], 548 | metadata={"plugins": {"datasette-graphql": {"num_queries_limit": 2}}}, 549 | ) 550 | query = """ 551 | { 552 | users { 553 | nodes { 554 | id 555 | name 556 | repos_list { 557 | nodes { 558 | full_name 559 | } 560 | } 561 | } 562 | } 563 | } 564 | """ 565 | response = await ds.client.post("/graphql", json={"query": query}) 566 | assert response.status_code == 500 567 | data_and_errors = response.json() 568 | data = data_and_errors["data"] 569 | errors = data_and_errors["errors"] 570 | users = data["users"]["nodes"] 571 | # One of the two users should have an empty repos_list 572 | assert (users[0]["repos_list"] is None) or (users[1]["repos_list"] is None) 573 | # Errors should say query limit was exceeded 574 | assert len(errors) == 1 575 | assert errors[0]["message"].startswith("Query limit exceeded: 3 > 2 - ") 576 | 577 | 578 | @pytest.mark.asyncio 579 | async def test_time_limits_0(db_path): 580 | ds = Datasette( 581 | [str(db_path)], 582 | metadata={ 583 | "plugins": { 584 | "datasette-graphql": {"num_queries_limit": 0, "time_limit_ms": 0} 585 | } 586 | }, 587 | ) 588 | query = """ 589 | { 590 | users { 591 | nodes { 592 | id 593 | name 594 | repos_list { 595 | nodes { 596 | full_name 597 | } 598 | } 599 | } 600 | } 601 | } 602 | """ 603 | response = await ds.client.post("/graphql", json={"query": query}) 604 | assert response.status_code == 200 605 | assert response.json() == { 606 | "data": { 607 | "users": { 608 | "nodes": [ 609 | { 610 | "id": 1, 611 | "name": "cleopaws", 612 | "repos_list": {"nodes": [{"full_name": "cleopaws/dogspotter"}]}, 613 | }, 614 | { 615 | "id": 2, 616 | "name": "simonw", 617 | "repos_list": { 618 | "nodes": [ 619 | {"full_name": "simonw/datasette"}, 620 | {"full_name": "simonw/private"}, 621 | ] 622 | }, 623 | }, 624 | ] 625 | } 626 | } 627 | } 628 | 629 | 630 | @pytest.mark.asyncio 631 | @pytest.mark.parametrize( 632 | "metadata,expected", 633 | [ 634 | ( 635 | # Disallow all access to both authenticated and anonymous users 636 | {"allow": False}, 637 | [ 638 | # Authenticated?, Path, Expected status code 639 | (False, "/graphql", 403), 640 | (False, "/graphql/test", 403), 641 | (False, "/graphql/test.graphql", 403), 642 | (False, "/graphql/test2", 403), 643 | (False, "/graphql/test2.graphql", 403), 644 | (True, "/graphql", 403), 645 | (True, "/graphql/test", 403), 646 | (True, "/graphql/test.graphql", 403), 647 | (True, "/graphql/test2", 403), 648 | (True, "/graphql/test2.graphql", 403), 649 | ], 650 | ), 651 | ( 652 | # Allow access to test, protect test2 653 | {"databases": {"test2": {"allow": {"id": "user"}}}}, 654 | [ 655 | # Authenticated?, Path, Expected status code 656 | (False, "/graphql", 200), 657 | (False, "/graphql/test", 200), 658 | (False, "/graphql/test.graphql", 200), 659 | (False, "/graphql/test2", 403), 660 | (False, "/graphql/test2.graphql", 403), 661 | (True, "/graphql", 200), 662 | (True, "/graphql/test", 200), 663 | (True, "/graphql/test.graphql", 200), 664 | (True, "/graphql/test2", 200), 665 | (True, "/graphql/test2.graphql", 200), 666 | ], 667 | ), 668 | ( 669 | # Forbid database instance access, but allow access to test2 670 | {"allow": False, "databases": {"test2": {"allow": True}}}, 671 | [ 672 | # Authenticated?, Path, Expected status code 673 | (False, "/graphql", 403), 674 | (False, "/graphql/test", 403), 675 | (False, "/graphql/test.graphql", 403), 676 | (False, "/graphql/test2", 200), 677 | (False, "/graphql/test2.graphql", 200), 678 | (True, "/graphql", 403), 679 | (True, "/graphql/test", 403), 680 | (True, "/graphql/test.graphql", 403), 681 | (True, "/graphql/test2", 200), 682 | (True, "/graphql/test2.graphql", 200), 683 | ], 684 | ), 685 | ], 686 | ) 687 | async def test_permissions(db_path, db_path2, metadata, expected): 688 | ds = Datasette([db_path, db_path2], metadata=metadata) 689 | for authenticated, path, expected_status in expected: 690 | ds._permission_checks.clear() 691 | cookies = {} 692 | if authenticated: 693 | cookies["ds_actor"] = ds.sign({"a": {"id": "user"}}, "actor") 694 | response = await ds.client.get( 695 | path, 696 | cookies=cookies, 697 | headers={"Accept": "text/html"}, 698 | ) 699 | assert response.status_code == expected_status 700 | 701 | 702 | @pytest.mark.asyncio 703 | async def test_no_error_on_empty_schema(): 704 | # https://github.com/simonw/datasette-graphql/issues/64 705 | ds = Datasette([], memory=True) 706 | response = await ds.client.get("/graphql", headers={"Accept": "text/html"}) 707 | assert response.status_code == 200 708 | 709 | 710 | @pytest.mark.asyncio 711 | @pytest.mark.parametrize( 712 | "table,graphql_table,columns", 713 | ( 714 | ( 715 | "repos", 716 | "repos", 717 | "id full_name name tags owner { id name } license { _key name }", 718 | ), 719 | ( 720 | "_csv_progress_", 721 | "t_csv_progress_", 722 | "id filename bytes_todo bytes_done rows_done started completed error", 723 | ), 724 | ), 725 | ) 726 | async def test_table_action(db_path, table, graphql_table, columns): 727 | ds = Datasette([str(db_path)]) 728 | db = ds.get_database("test") 729 | await db.execute_write( 730 | """ 731 | CREATE TABLE IF NOT EXISTS [_csv_progress_] ( 732 | [id] TEXT PRIMARY KEY, 733 | [filename] TEXT, 734 | [bytes_todo] INTEGER, 735 | [bytes_done] INTEGER, 736 | [rows_done] INTEGER, 737 | [started] TEXT, 738 | [completed] TEXT, 739 | [error] TEXT 740 | )""" 741 | ) 742 | response = await ds.client.get("/test/{}".format(table)) 743 | html = response.text 744 | prefix = '
  • ')[0] 747 | assert equal_no_whitespace( 748 | urllib.parse.unquote(example_query), 749 | textwrap.dedent( 750 | """ 751 | { 752 | TABLE { 753 | totalCount 754 | pageInfo { 755 | hasNextPage 756 | endCursor 757 | } 758 | nodes { 759 | COLUMNS 760 | } 761 | } 762 | } 763 | """.replace( 764 | "TABLE", graphql_table 765 | ).replace( 766 | "COLUMNS", columns 767 | ) 768 | ), 769 | ) 770 | 771 | 772 | @pytest.mark.asyncio 773 | async def test_graphql_reserved_column_names(ds): 774 | response = await ds.client.post( 775 | "/graphql", 776 | json={ 777 | "query": """{ 778 | table_with_reserved_columns { 779 | nodes { 780 | id 781 | if_ 782 | description_ 783 | } 784 | } 785 | }""" 786 | }, 787 | ) 788 | assert response.status_code == 200 789 | assert response.json() == { 790 | "data": { 791 | "table_with_reserved_columns": { 792 | "nodes": [ 793 | {"id": 1, "if_": "keyword if", "description_": "a description"} 794 | ] 795 | } 796 | } 797 | } 798 | 799 | 800 | @pytest.mark.asyncio 801 | async def test_graphql_dangerous_column_names(ds): 802 | response = await ds.client.post( 803 | "/graphql", 804 | json={ 805 | "query": """{ 806 | table_with_dangerous_columns { 807 | nodes { 808 | _0_Starts_With_Hash 809 | _0_double_underscore 810 | } 811 | } 812 | }""" 813 | }, 814 | ) 815 | assert response.status_code == 200 816 | assert response.json() == { 817 | "data": { 818 | "table_with_dangerous_columns": { 819 | "nodes": [{"_0_Starts_With_Hash": 2, "_0_double_underscore": 3}] 820 | } 821 | } 822 | } 823 | 824 | 825 | @pytest.mark.asyncio 826 | async def test_bad_foreign_keys(ds): 827 | # https://github.com/simonw/datasette-graphql/issues/79 828 | response = await ds.client.post( 829 | "/graphql", 830 | json={ 831 | "query": """{ 832 | bad_foreign_key { 833 | nodes { 834 | fk 835 | } 836 | } 837 | }""" 838 | }, 839 | ) 840 | assert response.status_code == 200 841 | assert response.json() == {"data": {"bad_foreign_key": {"nodes": [{"fk": 1}]}}} 842 | 843 | 844 | @pytest.mark.asyncio 845 | async def test_alternative_graphql(): 846 | graphql_path = "/-/graphql" 847 | ds = Datasette( 848 | [], 849 | memory=True, 850 | metadata={ 851 | "plugins": { 852 | "datasette-graphql": { 853 | "path": graphql_path, 854 | } 855 | } 856 | }, 857 | ) 858 | # First check for menu 859 | response = await ds.client.get("/") 860 | if graphql_path is None: 861 | assert "GraphQL API" not in response.text 862 | else: 863 | assert ( 864 | '
  • GraphQL API
  • '.format(graphql_path) 865 | in response.text 866 | ) 867 | # Now check for direct access 868 | if graphql_path is None: 869 | response = await ds.client.get("/graphql") 870 | assert response.status_code == 404 871 | else: 872 | response = await ds.client.get(graphql_path, headers={"Accept": "text/html"}) 873 | assert response.status_code == 200 874 | assert "GraphiQL" in response.text 875 | -------------------------------------------------------------------------------- /tests/test_plugin_hook.py: -------------------------------------------------------------------------------- 1 | from datasette import hookimpl 2 | from datasette.plugins import pm 3 | from datasette.app import Datasette 4 | import graphene 5 | import pytest 6 | 7 | 8 | @pytest.mark.asyncio 9 | async def test_graphql_extra_fields_hook(): 10 | class InfoPlugin: 11 | __name__ = "InfoPlugin" 12 | 13 | @hookimpl 14 | def graphql_extra_fields(self, datasette, database): 15 | class Info(graphene.ObjectType): 16 | "Extra information" 17 | key = graphene.String() 18 | value = graphene.String() 19 | 20 | async def info_resolver(root, info): 21 | db = datasette.get_database(database) 22 | result = await db.execute("select 1 + 1") 23 | return [ 24 | { 25 | "key": "static", 26 | "value": "static", 27 | }, 28 | { 29 | "key": "database", 30 | "value": database, 31 | }, 32 | {"key": "1+1", "value": result.single_value()}, 33 | ] 34 | 35 | return [ 36 | ( 37 | "info", 38 | graphene.Field( 39 | graphene.List(Info), 40 | description="List of extra info", 41 | resolver=info_resolver, 42 | ), 43 | ), 44 | ] 45 | 46 | pm.register(InfoPlugin(), name="undo") 47 | try: 48 | ds = Datasette([], memory=True) 49 | response = await ds.client.post( 50 | "/graphql", 51 | json={ 52 | "query": """{ 53 | info { 54 | key 55 | value 56 | } 57 | }""" 58 | }, 59 | ) 60 | assert response.status_code == 200 61 | assert response.json() == { 62 | "data": { 63 | "info": [ 64 | {"key": "static", "value": "static"}, 65 | {"key": "database", "value": "_memory"}, 66 | {"key": "1+1", "value": "2"}, 67 | ] 68 | } 69 | } 70 | finally: 71 | pm.unregister(name="undo") 72 | -------------------------------------------------------------------------------- /tests/test_schema_caching.py: -------------------------------------------------------------------------------- 1 | from datasette.app import Datasette 2 | from datasette_graphql.utils import schema_for_database, _schema_cache 3 | import sqlite_utils 4 | import pytest 5 | from unittest import mock 6 | import sys 7 | from .fixtures import build_database 8 | 9 | 10 | @pytest.mark.skipif( 11 | sys.version_info < (3, 8), 12 | reason="async mocks from patch() require 3.8 or higher - #52", 13 | ) 14 | @pytest.mark.asyncio 15 | @mock.patch("datasette_graphql.utils.schema_for_database") 16 | async def test_schema_caching(mock_schema_for_database, tmp_path_factory): 17 | mock_schema_for_database.side_effect = schema_for_database 18 | db_directory = tmp_path_factory.mktemp("dbs") 19 | db_path = db_directory / "schema.db" 20 | db = sqlite_utils.Database(db_path) 21 | build_database(db) 22 | 23 | # Previous tests will have populated the cache 24 | _schema_cache.clear() 25 | 26 | assert len(_schema_cache) == 0 27 | 28 | # The first hit should call schema_for_database 29 | assert not mock_schema_for_database.called 30 | ds = Datasette([str(db_path)]) 31 | response = await ds.client.get("/graphql/schema.graphql") 32 | assert response.status_code == 200 33 | assert "view_on_table_with_pkSort" in response.text 34 | 35 | assert mock_schema_for_database.called 36 | 37 | assert len(_schema_cache) == 1 38 | 39 | mock_schema_for_database.reset_mock() 40 | 41 | # The secod hit should NOT call it 42 | assert not mock_schema_for_database.called 43 | response = await ds.client.get("/graphql/schema.graphql") 44 | assert response.status_code == 200 45 | assert "view_on_table_with_pkSort" in response.text 46 | assert "new_table" not in response.text 47 | 48 | assert not mock_schema_for_database.called 49 | 50 | current_keys = set(_schema_cache.keys()) 51 | assert len(current_keys) == 1 52 | 53 | # We change the schema and it should be called again 54 | db["new_table"].insert({"new_column": 1}) 55 | 56 | response = await ds.client.get("/graphql/schema.graphql") 57 | assert response.status_code == 200 58 | assert "view_on_table_with_pkSort" in response.text 59 | assert "new_table" in response.text 60 | 61 | assert mock_schema_for_database.called 62 | 63 | assert len(_schema_cache) == 1 64 | assert set(_schema_cache.keys()) != current_keys 65 | -------------------------------------------------------------------------------- /tests/test_schema_for_database.py: -------------------------------------------------------------------------------- 1 | from datasette_graphql.utils import schema_for_database 2 | from graphql import graphql 3 | import pytest 4 | from .fixtures import ds, db_path 5 | 6 | 7 | @pytest.mark.asyncio 8 | async def test_schema(ds): 9 | schema = (await schema_for_database(ds)).schema 10 | 11 | query = """{ 12 | users { 13 | totalCount 14 | nodes { 15 | name 16 | points 17 | score 18 | } 19 | } 20 | }""" 21 | 22 | result = await schema.execute_async(query) 23 | assert result.data == { 24 | "users": { 25 | "totalCount": 2, 26 | "nodes": [ 27 | {"name": "cleopaws", "points": 5, "score": 51.5}, 28 | {"name": "simonw", "points": 3, "score": 35.2}, 29 | ], 30 | } 31 | }, result.errors 32 | -------------------------------------------------------------------------------- /tests/test_template_tag.py: -------------------------------------------------------------------------------- 1 | from datasette.app import Datasette 2 | import pytest 3 | from .fixtures import db_path 4 | 5 | TEMPLATE = r''' 6 | {% set users = graphql(""" 7 | { 8 | users { 9 | nodes { 10 | name 11 | points 12 | score 13 | } 14 | } 15 | } 16 | """)["users"] %} 17 | {% for user in users.nodes %} 18 |

    {{ user.name }} - points: {{ user.points }}, score = {{ user.score }}

    19 | {% endfor %} 20 | ''' 21 | 22 | TEMPLATE_WITH_VARS = r''' 23 | {% set user = graphql(""" 24 | query ($id: Int) { 25 | users_row(id: $id) { 26 | id 27 | name 28 | } 29 | } 30 | """, variables={"id": 2})["users_row"] %} 31 |

    {{ user.id }}: {{ user.name }}

    32 | ''' 33 | 34 | 35 | @pytest.mark.asyncio 36 | @pytest.mark.parametrize( 37 | "template,expected", 38 | [ 39 | ( 40 | TEMPLATE, 41 | "

    cleopaws - points: 5, score = 51.5

    \n\n" 42 | "

    simonw - points: 3, score = 35.2

    ", 43 | ), 44 | (TEMPLATE_WITH_VARS, "

    2: simonw

    "), 45 | ], 46 | ) 47 | async def test_schema_caching(tmp_path_factory, db_path, template, expected): 48 | template_dir = tmp_path_factory.mktemp("templates") 49 | pages_dir = template_dir / "pages" 50 | pages_dir.mkdir() 51 | (pages_dir / "about.html").write_text(template) 52 | 53 | ds = Datasette([str(db_path)], template_dir=template_dir) 54 | response = await ds.client.get("/about") 55 | assert response.status_code == 200 56 | assert response.text.strip() == expected 57 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from datasette_graphql import utils 2 | 3 | 4 | def test_namer(): 5 | n = utils.Namer() 6 | for input, expected in ( 7 | ("foo", "foo"), 8 | ("foo", "foo_2"), 9 | ("foo", "foo_3"), 10 | ("bar", "bar"), 11 | ("bar", "bar_2"), 12 | ("74_thang", "_74_thang"), 13 | ("74_thang", "_74_thang_2"), 14 | ("this has spaces", "this_has_spaces"), 15 | ("this$and&that", "this_and_that"), 16 | ): 17 | assert n.name(input) == expected 18 | --------------------------------------------------------------------------------