├── .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 | [](https://pypi.org/project/datasette-graphql/)
4 | [](https://github.com/simonw/datasette-graphql/releases)
5 | [](https://github.com/simonw/datasette-graphql/actions?query=workflow%3ATest)
6 | [](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 | 
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 |
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 |