├── .github └── workflows │ └── build_docs.yml ├── .gitignore ├── README.md ├── docs ├── .gitignore ├── .vscode │ └── settings.json ├── docs │ ├── images │ │ ├── account_by_username_query.png │ │ ├── fastgql shot.jpg │ │ ├── graphiql.png │ │ └── simple_movies.png │ ├── img │ │ └── emoji_logo.svg │ ├── index.md │ ├── stylesheets │ │ └── extra.css │ └── tutorial │ │ ├── index.md │ │ ├── more_advanced.md │ │ └── query_builder.md ├── docs_src │ ├── build_data.py │ ├── movies.py │ └── tutorial │ │ ├── movie_super_simple.py │ │ ├── movies_edgedb.py │ │ └── movies_qb.py ├── mkdocs.yml └── old │ ├── fastgql_old.md │ ├── index.md │ ├── index_old.md │ └── tutorial_index_old.md ├── fastgql ├── __init__.py ├── context.py ├── depends.py ├── execute │ ├── __init__.py │ ├── executor.py │ ├── resolver.py │ └── utils.py ├── gql_ast │ ├── __init__.py │ ├── models.py │ └── translator.py ├── gql_models.py ├── info.py ├── logs.py ├── query_builders │ ├── edgedb │ │ ├── config.py │ │ ├── logic.py │ │ ├── models.py │ │ └── query_builder.py │ └── sql │ │ ├── config.py │ │ ├── logic.py │ │ └── query_builder.py ├── scalars.py ├── schema_builder.py ├── static │ └── graphiql.html └── utils.py ├── mypy.ini ├── poetry.lock ├── pyproject.toml └── tests ├── __init__.py ├── for_docs ├── .gitignore ├── build_data.py ├── main.py ├── movie_super_simple.py ├── movies.py ├── movies_edgedb.py ├── movies_qb.py └── movies_simple.py ├── server ├── __init__.py ├── main.py ├── services │ └── users │ │ └── gql.py └── start.py ├── sqlparse_testing.py └── start.py /.github/workflows/build_docs.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - main 6 | permissions: 7 | contents: write 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-python@v4 14 | with: 15 | python-version: 3.x 16 | - uses: actions/cache@v2 17 | with: 18 | key: ${{ github.ref }} 19 | path: .cache 20 | - run: pip install mkdocs-material mdx_include 21 | - run: pip install pillow cairosvg 22 | - run: cd docs && mkdocs gh-deploy --force -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache 2 | .coverage 3 | htmlcov 4 | *.DS_Store* 5 | .run 6 | *.idea* 7 | dist* 8 | __pycache__* 9 | venv* 10 | .ruff_cache -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Presentation](https://youtu.be/r--YN_6a76g?si=aWdBFagVPJfWDD6K&t=2155) 2 | [![](docs/docs/images/fastgql%20shot.jpg)](https://youtu.be/r--YN_6a76g?si=aWdBFagVPJfWDD6K&t=2155) 3 | # [Documentation](https://jerber.github.io/fastgql) 4 | # FastGQL: The fastest GraphQL Framework 5 | 6 | **FastGQL** is a python GraphQL library that uses Pydantic models to build GraphQL types. Think FastAPI for GraphQL. 7 | 8 | ```py 9 | from fastapi import FastAPI 10 | from fastgql import GQL, build_router 11 | 12 | class User(GQL): 13 | name: str 14 | age: int 15 | 16 | class Query(GQL): 17 | def user_by_name(self, name: str) -> User: 18 | return User(name=name, age=27) 19 | 20 | router = build_router(query_models=[Query]) 21 | 22 | app = FastAPI() 23 | 24 | app.include_router(router, prefix="/graphql") 25 | ``` 26 | I built **FastGQL** because I wanted a GraphQL framework that 27 | 28 | 1. let me use `pydantic.BaseModel` objects to define my schema 29 | 2. let me build dynamic database queries based on incoming requests 30 | 31 | We are now using **FastGQL** in production and have experienced a massive (10x) speedup in average response times because of 2). 32 | 33 | You can find out more about how we build dynamic database queries in the Advanced Tutorial section of the docs. 34 | 35 | ## Installation 36 | 37 |
38 | 39 | ```console 40 | $ pip install fastgql 41 | ---> 100% 42 | Successfully installed fastgql 43 | ``` 44 | 45 |
46 | 47 | ## Example 48 | 49 | All you need to do is create objects that inherit from `fastgql.GQL`, which is a simple subclass of `pydantic.BaseModel`. For this example, I am creating a mock schema based on movies. For the functions, you'd usually use a database but I hardcoded the data for this example. 50 | 51 | This code generates a GraphQL schema, reading the object fields and functions. Functions can be sync or async. 52 | 53 | ### Code it 54 | 55 | - Create a file `main.py` with: 56 | 57 | ```py title="main.py" 58 | from uuid import UUID, uuid4 59 | from fastapi import FastAPI 60 | from fastgql import GQL, build_router 61 | 62 | class Account(GQL): # (4)! 63 | id: UUID 64 | username: str 65 | 66 | def watchlist(self) -> list["Movie"]: # (1)! 67 | # Usually you'd use a database to get the user's watchlist. For this example, it is hardcoded. 68 | return [ 69 | Movie(id=uuid4(), title="Barbie", release_year=2023), 70 | Movie(id=uuid4(), title="Oppenheimer", release_year=2023), 71 | ] 72 | 73 | def _secret_function(self) -> str: # (2)! 74 | return "this is not exposed!" 75 | 76 | class Person(GQL): 77 | id: UUID 78 | name: str 79 | 80 | def filmography(self) -> list["Movie"]: 81 | return [ 82 | Movie(id=uuid4(), title="Barbie", release_year=2023), 83 | Movie(id=uuid4(), title="Wolf of Wallstreet", release_year=2013), 84 | ] 85 | 86 | class Movie(GQL): 87 | id: UUID 88 | title: str 89 | release_year: int 90 | 91 | def actors(self) -> list["Person"]: 92 | return [ 93 | Person(id=uuid4(), name="Margot Robbie"), 94 | Person(id=uuid4(), name="Ryan Gosling"), 95 | ] 96 | 97 | class Query(GQL): 98 | def account_by_username(self, username: str) -> Account: # (5)! 99 | # Usually you'd use a database to get this account. For this example, it is hardcoded. 100 | return Account(id=uuid4(), username=username) 101 | 102 | router = build_router(query_models=[Query]) 103 | 104 | app = FastAPI() # (3)! 105 | 106 | app.include_router(router, prefix="/graphql") 107 | ``` 108 | 109 | 1. Usually this would be a database call. There is an advanced tutorial showing this. 110 | 2. Functions that start with `_` are not included in the GraphQL schema. 111 | 3. This is just a normal FastAPI app. `fastgql.build_router` returns a router that can be included on any FastAPI app. 112 | 4. These objects are subclasses of `pydantic.BaseModel`, so anything you'd want to do with a `BaseModel` you can do with these. You'll see how this comes in handy in future tutorials. 113 | 5. All of these functions can be sync or async. In a future tutorial I'll use an async database call to get data. 114 | 115 | ### Run it 116 | 117 | Run the server with: 118 | 119 |
120 | 121 | ```console 122 | $ uvicorn main:app --reload 123 | 124 | INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) 125 | INFO: Started reloader process [28720] 126 | INFO: Started server process [28722] 127 | INFO: Waiting for application startup. 128 | INFO: Application startup complete. 129 | ``` 130 | 131 |
132 | 133 |
134 | (Taken from FastAPI docs) About the command uvicorn main:app --reload... 135 | 136 | The command `uvicorn main:app` refers to: 137 | 138 | - `main`: the file `main.py` (the Python "module"). 139 | - `app`: the object created inside of `main.py` with the line `app = FastAPI()`. 140 | - `--reload`: make the server restart after code changes. Only do this for development. 141 | 142 |
143 | 144 | ### Check it 145 | 146 | Open your browser at http://127.0.0.1:8000/graphql. 147 | 148 | You will see a GraphiQL UI. This is your homebase for creating GraphQL queries and checking the schema. 149 | 150 | ![](docs/docs/images/graphiql.png) 151 | 152 | You can see the schema, build queries, and access query history from the icons in the top left respectivly. Here is an example query: 153 | 154 | ![](docs/docs/images/account_by_username_query.png) 155 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ -------------------------------------------------------------------------------- /docs/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "yaml.schemas": { 3 | "https://squidfunk.github.io/mkdocs-material/schema.json": "file:///Users/jerber/code/open_source/fastgql/fastgql/docs/mkdocs.yml" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /docs/docs/images/account_by_username_query.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerber/fastgql/064c1e846b1758a967d1464c50d7fef6b9f095b9/docs/docs/images/account_by_username_query.png -------------------------------------------------------------------------------- /docs/docs/images/fastgql shot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerber/fastgql/064c1e846b1758a967d1464c50d7fef6b9f095b9/docs/docs/images/fastgql shot.jpg -------------------------------------------------------------------------------- /docs/docs/images/graphiql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerber/fastgql/064c1e846b1758a967d1464c50d7fef6b9f095b9/docs/docs/images/graphiql.png -------------------------------------------------------------------------------- /docs/docs/images/simple_movies.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerber/fastgql/064c1e846b1758a967d1464c50d7fef6b9f095b9/docs/docs/images/simple_movies.png -------------------------------------------------------------------------------- /docs/docs/img/emoji_logo.svg: -------------------------------------------------------------------------------- 1 | 💨 -------------------------------------------------------------------------------- /docs/docs/index.md: -------------------------------------------------------------------------------- 1 | # FastGQL 2 | 3 | **FastGQL** is a python GraphQL library that uses Pydantic models to build GraphQL types. Think FastAPI for GraphQL. 4 | 5 | ```py 6 | from fastapi import FastAPI 7 | from fastgql import GQL, build_router 8 | 9 | class User(GQL): 10 | name: str 11 | age: int 12 | 13 | class Query(GQL): 14 | def user_by_name(self, name: str) -> User: 15 | return User(name=name, age=27) 16 | 17 | router = build_router(query_models=[Query]) 18 | 19 | app = FastAPI() 20 | 21 | app.include_router(router, prefix="/graphql") 22 | ``` 23 | I built **FastGQL** because I wanted a GraphQL framework that 24 | 25 | 1. let me use `pydantic.BaseModel` objects to define my schema 26 | 2. let me build dynamic database queries based on incoming requests 27 | 28 | We are now using **FastGQL** in production and have experienced a massive (10x) speedup in average response times because of 2). 29 | 30 | You can find out more about how we build dynamic database queries in the Advanced Tutorial section of the docs. 31 | 32 | ## Installation 33 | 34 |
35 | 36 | ```console 37 | $ pip install fastgql 38 | ---> 100% 39 | Successfully installed fastgql 40 | ``` 41 | 42 |
43 | 44 | ## Example 45 | 46 | All you need to do is create objects that inherit from `fastgql.GQL`, which is a simple subclass of `pydantic.BaseModel`. For this example, I am creating a mock schema based on movies. For the functions, you'd usually use a database but I hardcoded the data for this example. 47 | 48 | This code generates a GraphQL schema, reading the object fields and functions. Functions can be sync or async. 49 | 50 | ### Code it 51 | 52 | - Create a file `main.py` with: 53 | 54 | ```py title="main.py" 55 | from uuid import UUID, uuid4 56 | from fastapi import FastAPI 57 | from fastgql import GQL, build_router 58 | 59 | class Account(GQL): # (4)! 60 | id: UUID 61 | username: str 62 | 63 | def watchlist(self) -> list["Movie"]: # (1)! 64 | # Usually you'd use a database to get the user's watchlist. For this example, it is hardcoded. 65 | return [ 66 | Movie(id=uuid4(), title="Barbie", release_year=2023), 67 | Movie(id=uuid4(), title="Oppenheimer", release_year=2023), 68 | ] 69 | 70 | def _secret_function(self) -> str: # (2)! 71 | return "this is not exposed!" 72 | 73 | class Person(GQL): 74 | id: UUID 75 | name: str 76 | 77 | def filmography(self) -> list["Movie"]: 78 | return [ 79 | Movie(id=uuid4(), title="Barbie", release_year=2023), 80 | Movie(id=uuid4(), title="Wolf of Wallstreet", release_year=2013), 81 | ] 82 | 83 | class Movie(GQL): 84 | id: UUID 85 | title: str 86 | release_year: int 87 | 88 | def actors(self) -> list["Person"]: 89 | return [ 90 | Person(id=uuid4(), name="Margot Robbie"), 91 | Person(id=uuid4(), name="Ryan Gosling"), 92 | ] 93 | 94 | class Query(GQL): 95 | def account_by_username(self, username: str) -> Account: # (5)! 96 | # Usually you'd use a database to get this account. For this example, it is hardcoded. 97 | return Account(id=uuid4(), username=username) 98 | 99 | router = build_router(query_models=[Query]) 100 | 101 | app = FastAPI() # (3)! 102 | 103 | app.include_router(router, prefix="/graphql") 104 | ``` 105 | 106 | 1. Usually this would be a database call. There is an advanced tutorial showing this. 107 | 2. Functions that start with `_` are not included in the GraphQL schema. 108 | 3. This is just a normal FastAPI app. `fastgql.build_router` returns a router that can be included on any FastAPI app. 109 | 4. These objects are subclasses of `pydantic.BaseModel`, so anything you'd want to do with a `BaseModel` you can do with these. You'll see how this comes in handy in future tutorials. 110 | 5. All of these functions can be sync or async. In a future tutorial I'll use an async database call to get data. 111 | 112 | ### Run it 113 | 114 | Run the server with: 115 | 116 |
117 | 118 | ```console 119 | $ uvicorn main:app --reload 120 | 121 | INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) 122 | INFO: Started reloader process [28720] 123 | INFO: Started server process [28722] 124 | INFO: Waiting for application startup. 125 | INFO: Application startup complete. 126 | ``` 127 | 128 |
129 | 130 |
131 | (Taken from FastAPI docs) About the command uvicorn main:app --reload... 132 | 133 | The command `uvicorn main:app` refers to: 134 | 135 | - `main`: the file `main.py` (the Python "module"). 136 | - `app`: the object created inside of `main.py` with the line `app = FastAPI()`. 137 | - `--reload`: make the server restart after code changes. Only do this for development. 138 | 139 |
140 | 141 | ### Check it 142 | 143 | Open your browser at http://127.0.0.1:8000/graphql. 144 | 145 | You will see a GraphiQL UI. This is your homebase for creating GraphQL queries and checking the schema. 146 | 147 | ![](images/graphiql.png) 148 | 149 | You can see the schema, build queries, and access query history from the icons in the top left respectivly. Here is an example query: 150 | 151 | ![](images/account_by_username_query.png) 152 | -------------------------------------------------------------------------------- /docs/docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --md-primary-fg-color: #8DA4EF; 3 | /* --md-primary-fg-color--light: #C0C0C0; */ 4 | /* --md-primary-fg-color--dark: #80030C; */ 5 | 6 | --md-accent-fg-color: #AB4ABA; 7 | /* --md-accent-fg-color--transparent: hsla(#{hex2hsl($clr-indigo-a200)}, 0.1); */ 8 | /* --md-accent-bg-color: hsla(0, 0%, 100%, 1); */ 9 | /* --md-accent-bg-color--light: hsla(0, 0%, 100%, 0.7); */ 10 | } 11 | -------------------------------------------------------------------------------- /docs/docs/tutorial/index.md: -------------------------------------------------------------------------------- 1 | # Intro, Installation, and First Steps 2 | 3 | This tutorial assumes you know the basics of GraphQL. If you don't, I suggest checking out this great [tutorial](https://graphql.org/learn/) first. 4 | 5 | ### Create a project and install FastGQL 6 | 7 | Create a folder: 8 | 9 | ```bash 10 | mkdir fastgql-tutorial 11 | cd fastgql-tutorial 12 | ``` 13 | 14 | Now, we'll create a virtual environment. This allows us to install python libraries scoped to this project. 15 | 16 | First, ensure you have a python version of 3.10 or greater. You can check this by running: 17 | 18 | ``` 19 | python --version 20 | ``` 21 | 22 | If you do not have python 3.10 or greater, install that now. 23 | 24 | ```bash 25 | python -m venv virtualenv 26 | ``` 27 | 28 | Now we need to activate the virtual environment. 29 | 30 | ```bash 31 | source virtualenv/bin/activate 32 | ``` 33 | 34 | Now we can install **FastGQL**: 35 | 36 | ```bash 37 | pip install fastgql 38 | ``` 39 | 40 | **FastGQL** installs with [**FastAPI**](https://fastapi.tiangolo.com/) and [**Pydantic V2**](https://docs.pydantic.dev/latest/). You will not need to install a seperate web server library. 41 | 42 | ### Define the Schema 43 | 44 | !!! info 45 | For the sake of simplicity, all code in for this tutorial will be in one file. 46 | 47 |
48 | Full file 👀 49 | 50 | ```Python 51 | {!./docs_src/tutorial/movie_super_simple.py!} 52 | ``` 53 | 54 |
55 | 56 | **FastGQL** works by reading objects inheriting from `fastgql.GQL` and constructing a GraphQL schema. It reads both the fields and functions of the object. `GQL` is a simple subclass of `pydantic.BaseModel` and has all of the functionality of a `BaseModel`. 57 | 58 | Even the root `Query` is a `GQL` type. 59 | 60 | Create the file `schema.py`: 61 | 62 | ```python title="schema.py" 63 | from fastapi import FastAPI 64 | from fastgql import GQL, build_router 65 | 66 | class Actor(GQL): 67 | name: str 68 | 69 | class Movie(GQL): 70 | title: str 71 | release_year: int 72 | actors: list[Actor] 73 | 74 | class Query(GQL): 75 | def get_movies(self) -> list[Movie]: 76 | return [ 77 | Movie( 78 | title="Barbie", 79 | release_year=2023, 80 | actors=[Actor(name="Margot Robbie")], 81 | ) 82 | ] 83 | ``` 84 | 85 | ### Build our schema and run it 86 | 87 | Under the hood, **FastGQL** creates a **FastAPI** router that executes incoming GraphQL queries. If you're unfamiliar with **FastAPI**, it is worth checking out their excellent [docs](https://fastapi.tiangolo.com/). 88 | 89 | ```python title="schema.py" 90 | router = build_router(query_models=[Query]) # (1)! 91 | 92 | app = FastAPI() # (2)! 93 | 94 | app.include_router(router, prefix="/graphql") # (3)! 95 | ``` 96 | 97 | 1. This is where we build the **FastAPI** router with our schema 98 | 2. Initialize a new FastAPI instance for your app. This can be any app, including one already created elsewhere. 99 | 3. Attach the router to the app and include whatever prefix you'd like the GraphQL endpoint to be reached at. 100 | 101 |
102 | 👀 Full file preview 103 | 104 | ```Python 105 | {!./docs_src/tutorial/movie_super_simple.py!} 106 | ``` 107 | 108 |
109 | 110 | The easiest way to run this **FastAPI** server is with [**Uvicorn**](https://www.uvicorn.org/), which is a fast async web server. 111 | 112 | ```bash 113 | uvicorn schema:app --reload # (1)! 114 | ``` 115 | 116 | 1. A good explaination of **Uvicorn** can be found [here](https://fastapi.tiangolo.com/#run-it). 117 | 118 | ### Query it 119 | 120 | It is time to execute your first query! Go to `http://0.0.0.0:8000/graphql` where you should see a GUI for GraphQL called GraphiQL. 121 | 122 | ![](../images/graphiql.png) 123 | 124 | Paste this query into the text box and hit the play button: 125 | 126 | ```graphql 127 | { 128 | getMovies { 129 | title 130 | releaseYear 131 | actors { 132 | name 133 | } 134 | } 135 | } 136 | ``` 137 | 138 | You should see the data we made in `schema.py` come back 🎉 139 | 140 | ![](../images/simple_movies.png) 141 | 142 | Now, we will move on to more advanced tutorials! -------------------------------------------------------------------------------- /docs/docs/tutorial/more_advanced.md: -------------------------------------------------------------------------------- 1 | # Advanced Tutorial 2 | 3 | !!! tip 4 | I am still working on the docs. Feel free to DM me on Twitter if you have questions or feedback. 5 | 6 | The reason I built **FastGQL** was to address the `n+1` problem I had with other frameworks. 7 | 8 | Consider this working example using [`EdgeDB`](https://edgedb.com) as the database: 9 | 10 |
11 | Full file 👀 12 | 13 | ```Python 14 | {!./docs_src/tutorial/movies_edgedb.py!} 15 | ``` 16 | 17 |
18 | 19 | For each connection, we have to make a new query. For example, if your query is: 20 | 21 | ```graphql 22 | { 23 | accountByUsername(username: "Cameron") { 24 | id 25 | username 26 | watchlist(limit: 100) { 27 | __typename 28 | ...on Movie { 29 | id 30 | title 31 | releaseYear 32 | actors { 33 | name 34 | } 35 | } 36 | ... on Show { 37 | id 38 | title 39 | } 40 | } 41 | } 42 | } 43 | ``` 44 | 45 | You get back a lot of nested data. For each nested data you get, that's another database call. For example, to get actors from a movie: 46 | 47 | ```python 48 | class Content(GQLInterface): 49 | id: UUID 50 | title: str 51 | 52 | async def actors(self) -> list["Person"]: 53 | q = """select Content { actors: { id, name } } filter .id = $id""" 54 | content_d = await query_required_single_json( 55 | name="content.actors", query=q, id=self.id 56 | ) 57 | return [Person(**p) for p in content_d["actors"]] 58 | ``` 59 | 60 | So, to execute this query, the server had to: 61 | 1) get the account by username from the database, 62 | 2) get the watchlist of that user from the database, 63 | 3) get the actor of each movie from the database 64 | 65 | There are some solutions to make this process more efficient. One of them is using [dataloaders](https://xuorig.medium.com/the-graphql-dataloader-pattern-visualized-3064a00f319f). 66 | 67 | However, even with a dataloader, you are still making new requests to the database for each new level of data you are requesting. 68 | 69 | **FastGQL** comes with a way to solve this problem. It ships with `QueryBuilder` functionality. This allows you to map your GraphQL schema to your database schema, which means you can dynamically generate the exact database query you need to fulfill the client's request. 70 | 71 | !!! note 72 | Currently `QueryBuilder` only works with `EdgeDB`. 73 | 74 | Here is a full example of the same schema, now using the `QueryBuilder` feature. 75 | 76 |
77 | Full file 👀 78 | 79 | ```Python 80 | {!./docs_src/tutorial/movies_qb.py!} 81 | ``` 82 | 83 |
84 | 85 | Now this same query: 86 | ```graphql 87 | { 88 | accountByUsername(username: "Cameron") { 89 | id 90 | username 91 | watchlist(limit: 100) { 92 | __typename 93 | ...on Movie { 94 | id 95 | title 96 | releaseYear 97 | actors { 98 | name 99 | } 100 | } 101 | ... on Show { 102 | id 103 | title 104 | } 105 | } 106 | } 107 | } 108 | ``` 109 | executes with only one call to the database that looks like this: 110 | ``` 111 | select Account { id, username, watchlist: { typename := .__type__.name, Movie := (select [is Movie] { __typename := .__type__.name, id, release_year, title, actors: { name } }), Show := (select [is Show] { __typename := .__type__.name, id, title }) } LIMIT $limit } filter .username = $username 112 | ``` 113 | 114 | The original query took around 180ms to execute and make 6 database calls. 115 | 116 | The new query using `QueryBuilders` takes less than 30ms to execute and only makes one database call! 117 | 118 | For this small example, the results are not so dramatic. But in production, on large datasets, the speed advantage can easily be 10x. -------------------------------------------------------------------------------- /docs/docs/tutorial/query_builder.md: -------------------------------------------------------------------------------- 1 | # Query Builder Tutorial 2 | 3 | I will be adding this later. Please dm me on twitter if you'd like me to make it sooner. -------------------------------------------------------------------------------- /docs/docs_src/build_data.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4, UUID 2 | 3 | people = [ 4 | {"id": uuid4(), "name": "Margot Robbie"}, 5 | {"id": uuid4(), "name": "Ryan Gosling"}, 6 | {"id": uuid4(), "name": "Jeremy Allen White"}, 7 | ] 8 | movies = [ 9 | {"id": uuid4(), "title": "Barbie", "release_year": 2023, "actors": people[0:2]} 10 | ] 11 | shows = [ 12 | { 13 | "id": uuid4(), 14 | "title": "Game Of Thrones", 15 | "seasons": [{"id": uuid4(), "number": x} for x in range(1, 9)], 16 | "actors": people[2:], 17 | } 18 | ] 19 | content = [*movies, *shows] 20 | content_by_person_id = {} 21 | for p in people: 22 | person_id = p["id"] 23 | filmography = [] 24 | for c in content: 25 | if person_id in [c_actor["id"] for c_actor in c["actors"]]: 26 | filmography.append(c) 27 | content_by_person_id[person_id] = filmography 28 | movies_by_id: dict[UUID, dict] = {m["id"]: m for m in movies} 29 | shows_by_id: dict[UUID, dict] = {s["id"]: s for s in shows} 30 | content_by_id: dict[UUID, dict] = {c["id"]: c for c in content} 31 | accounts = [{"id": uuid4(), "username": "jeremy", "watchlist": content}] 32 | accounts_by_username = {a["username"]: a for a in accounts} 33 | -------------------------------------------------------------------------------- /docs/docs_src/movies.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | from uuid import UUID 3 | from pydantic import TypeAdapter 4 | from fastapi import FastAPI 5 | from fastgql import GQL, GQLInterface, build_router 6 | from .build_data import ( 7 | accounts_by_username, 8 | content_by_person_id, 9 | content_by_id, 10 | shows_by_id, 11 | ) 12 | 13 | Contents = list[T.Union["Movie", "Show"]] 14 | 15 | 16 | class Account(GQL): 17 | id: UUID 18 | username: str 19 | 20 | def watchlist(self) -> Contents: 21 | """create a list of movies and shows""" 22 | watchlist_raw = accounts_by_username[self.username]["watchlist"] 23 | return TypeAdapter(Contents).validate_python(watchlist_raw) 24 | 25 | 26 | class Person(GQL): 27 | id: UUID 28 | name: str 29 | 30 | def filmography(self) -> Contents: 31 | return TypeAdapter(Contents).validate_python(content_by_person_id[self.id]) 32 | 33 | 34 | class Content(GQLInterface): 35 | id: UUID 36 | title: str 37 | 38 | def actors(self) -> list["Person"]: 39 | return [Person(**p) for p in content_by_id[self.id]["actors"]] 40 | 41 | 42 | class Movie(Content): 43 | id: UUID 44 | release_year: int 45 | 46 | 47 | class Show(Content): 48 | id: UUID 49 | 50 | def seasons(self) -> list["Season"]: 51 | return [Season(**s) for s in shows_by_id[self.id]["seasons"]] 52 | 53 | def num_seasons(self) -> int: 54 | return len(self.seasons()) 55 | 56 | 57 | class Season(GQL): 58 | id: UUID 59 | number: int 60 | show: "Show" 61 | 62 | 63 | class Query(GQL): 64 | @staticmethod 65 | async def account_by_username(username: str) -> Account: 66 | account = accounts_by_username[username] 67 | return Account(**account) 68 | 69 | 70 | router = build_router(query_models=[Query]) 71 | 72 | app = FastAPI() 73 | 74 | app.include_router(router, prefix="/graphql") 75 | -------------------------------------------------------------------------------- /docs/docs_src/tutorial/movie_super_simple.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastgql import GQL, build_router 3 | 4 | 5 | class Actor(GQL): 6 | name: str 7 | 8 | 9 | class Movie(GQL): 10 | title: str 11 | release_year: int 12 | actors: list[Actor] 13 | 14 | 15 | class Query(GQL): 16 | def get_movies(self) -> list[Movie]: 17 | return [ 18 | Movie( 19 | title="Barbie", 20 | release_year=2023, 21 | actors=[Actor(name="Margot Robbie")], 22 | ) 23 | ] 24 | 25 | 26 | router = build_router(query_models=[Query]) 27 | 28 | app = FastAPI() 29 | 30 | app.include_router(router, prefix="/graphql") 31 | -------------------------------------------------------------------------------- /docs/docs_src/tutorial/movies_edgedb.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | import typing as T 4 | from uuid import UUID 5 | import edgedb 6 | from pydantic import TypeAdapter 7 | from fastapi import FastAPI 8 | from fastgql import GQL, GQLInterface, build_router 9 | from dotenv import load_dotenv 10 | 11 | load_dotenv() 12 | 13 | edgedb_client = edgedb.create_async_client() 14 | 15 | Contents = list[T.Union["Movie", "Show"]] 16 | 17 | 18 | async def query_required_single_json( 19 | name: str, query: str, **variables 20 | ) -> dict[str, T.Any]: 21 | start = time.time() 22 | res = json.loads( 23 | await edgedb_client.query_required_single_json(query=query, **variables) 24 | ) 25 | took_ms = round((time.time() - start) * 1_000, 2) 26 | print(f"[{name}] took {took_ms} ms") 27 | return res 28 | 29 | 30 | class Account(GQL): 31 | id: UUID 32 | username: str 33 | 34 | async def watchlist(self, limit: int) -> Contents: 35 | q = """select Account { 36 | watchlist: { id, title, release_year := [is Movie].release_year } limit $limit 37 | } filter .id = $id""" 38 | account_d = await query_required_single_json( 39 | name="account.watchlist", query=q, id=self.id, limit=limit 40 | ) 41 | return TypeAdapter(Contents).validate_python(account_d["watchlist"]) 42 | 43 | 44 | class Person(GQL): 45 | id: UUID 46 | name: str 47 | 48 | async def filmography(self) -> Contents: 49 | q = """select Person { 50 | filmography: { id, title, release_year := [is Movie].release_year } 51 | } filter .id = $id""" 52 | person_d = await query_required_single_json( 53 | name="person.filmography", query=q, id=self.id 54 | ) 55 | return TypeAdapter(Contents).validate_python(person_d["filmography"]) 56 | 57 | 58 | class Content(GQLInterface): 59 | id: UUID 60 | title: str 61 | 62 | async def actors(self) -> list["Person"]: 63 | q = """select Content { actors: { id, name } } filter .id = $id""" 64 | content_d = await query_required_single_json( 65 | name="content.actors", query=q, id=self.id 66 | ) 67 | return [Person(**p) for p in content_d["actors"]] 68 | 69 | 70 | class Movie(Content): 71 | release_year: int 72 | 73 | 74 | class Show(Content): 75 | async def seasons(self) -> list["Season"]: 76 | q = """select Show { season := .$id""" 77 | show_d = await query_required_single_json( 78 | name="show.seasons", query=q, id=self.id 79 | ) 80 | return [Season(**s) for s in show_d["season"]] 81 | 82 | async def num_seasons(self) -> int: 83 | q = """select Show { num_seasons } filter .id = $id""" 84 | show_d = await query_required_single_json( 85 | name="show.num_seasons", query=q, id=self.id 86 | ) 87 | return show_d["num_seasons"] 88 | 89 | 90 | class Season(GQL): 91 | id: UUID 92 | number: int 93 | 94 | async def show(self) -> Show: 95 | q = """select Season { show: { id, title } } filter .id = $id""" 96 | season_d = await query_required_single_json( 97 | name="season.show", query=q, id=self.id 98 | ) 99 | return Show(**season_d["show"]) 100 | 101 | 102 | class Query(GQL): 103 | @staticmethod 104 | async def account_by_username(username: str) -> Account: 105 | q = """select Account { id, username } filter .username = $username""" 106 | account_d = await query_required_single_json( 107 | name="account_by_username", query=q, username=username 108 | ) 109 | return Account(**account_d) 110 | 111 | 112 | router = build_router(query_models=[Query]) 113 | 114 | app = FastAPI() 115 | 116 | app.include_router(router, prefix="/graphql") 117 | -------------------------------------------------------------------------------- /docs/docs_src/tutorial/movies_qb.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | import typing as T 4 | from uuid import UUID 5 | import edgedb 6 | from fastapi import FastAPI 7 | from fastgql import ( 8 | GQL, 9 | GQLInterface, 10 | build_router, 11 | Link, 12 | Property, 13 | get_qb, 14 | QueryBuilder, 15 | Depends, 16 | Info, 17 | node_from_path, 18 | ) 19 | from dotenv import load_dotenv 20 | 21 | load_dotenv() 22 | 23 | edgedb_client = edgedb.create_async_client() 24 | 25 | Contents = list[T.Union["Movie", "Show"]] 26 | 27 | 28 | def parse_raw_content(raw_content: list[dict, T.Any]) -> Contents: 29 | w_list: Contents = [] 30 | for item in raw_content: 31 | if item["typename"] == "default::Movie": 32 | if movie := item.get("Movie"): 33 | w_list.append(Movie(**movie)) 34 | elif item["typename"] == "default::Show": 35 | if show := item.get("Show"): 36 | w_list.append(Show(**show)) 37 | return w_list 38 | 39 | 40 | async def query_required_single_json( 41 | name: str, query: str, **variables 42 | ) -> dict[str, T.Any]: 43 | start = time.time() 44 | res = json.loads( 45 | await edgedb_client.query_required_single_json(query=query, **variables) 46 | ) 47 | took_ms = round((time.time() - start) * 1_000, 2) 48 | print(f"[{name}] took {took_ms} ms") 49 | return res 50 | 51 | 52 | class AccountPageInfo(GQL): 53 | has_next_page: bool 54 | has_previous_page: bool 55 | start_cursor: str | None 56 | end_cursor: str | None 57 | 58 | 59 | class AccountEdge(GQL): 60 | cursor: str 61 | node: "Account" 62 | 63 | 64 | class AccountConnection(GQL): 65 | page_info: AccountPageInfo 66 | edges: list[AccountEdge] 67 | total_count: int 68 | 69 | 70 | def update_watchlist(child_qb: QueryBuilder, limit: int) -> None: 71 | child_qb.set_limit(limit) 72 | 73 | 74 | class Account(GQL): 75 | def __init__(self, **data): 76 | super().__init__(**data) 77 | self._data = data 78 | 79 | id: T.Annotated[UUID, Property(db_name="id")] = None 80 | username: T.Annotated[str, Property(db_name="username")] = None 81 | 82 | async def watchlist( 83 | self, info: Info, limit: int 84 | ) -> T.Annotated[Contents, Link(db_name="watchlist", update_qbs=update_watchlist)]: 85 | return parse_raw_content(raw_content=self._data[info.path[-1]]) 86 | 87 | 88 | class Content(GQLInterface): 89 | def __init__(self, **data): 90 | super().__init__(**data) 91 | self._data = data 92 | 93 | id: T.Annotated[UUID, Property(db_name="id")] = None 94 | title: T.Annotated[str, Property(db_name="title")] = None 95 | 96 | async def actors( 97 | self, info: Info 98 | ) -> T.Annotated[list["Person"], Link(db_name="actors")]: 99 | return [Person(**p) for p in self._data[info.path[-1]]] 100 | 101 | 102 | class Movie(Content): 103 | release_year: T.Annotated[int, Property(db_name="release_year")] = None 104 | 105 | 106 | class Show(Content): 107 | num_seasons: T.Annotated[int, Property(db_name="num_seasons")] = None 108 | 109 | async def seasons( 110 | self, info: Info 111 | ) -> T.Annotated[list["Season"], Link(db_name=" T.Annotated[Show, Link(db_name="show")]: 124 | return Show(**self._data[info.path[-1]]) 125 | 126 | 127 | class Person(GQL): 128 | def __init__(self, **data): 129 | super().__init__(**data) 130 | self._data = data 131 | 132 | id: T.Annotated[UUID, Property(db_name="id")] = None 133 | name: T.Annotated[str, Property(db_name="name")] = None 134 | 135 | async def filmography( 136 | self, info: Info 137 | ) -> T.Annotated[Contents, Link(db_name="filmography")]: 138 | return parse_raw_content(raw_content=self._data[info.path[-1]]) 139 | 140 | 141 | AccountEdge.model_rebuild() 142 | 143 | 144 | class Query(GQL): 145 | @staticmethod 146 | async def account_by_username( 147 | username: str, qb: QueryBuilder = Depends(get_qb) 148 | ) -> Account: 149 | s, v = qb.build() 150 | q = f"""select Account {s} filter .username = $username""" 151 | print(q) 152 | account_d = await query_required_single_json( 153 | name="account_by_username", query=q, username=username, **v 154 | ) 155 | return Account(**account_d) 156 | 157 | @staticmethod 158 | async def account_connection( 159 | info: Info, 160 | *, 161 | before: str | None = None, 162 | after: str | None = None, 163 | first: int, 164 | ) -> AccountConnection: 165 | qb: QueryBuilder = await Account.qb_config.from_info( 166 | info=info, node=node_from_path(node=info.node, path=["edges", "node"]) 167 | ) 168 | qb.fields.add("username") 169 | variables = {"first": first} 170 | filter_list: list[str] = [] 171 | if before: 172 | filter_list.append(".username > $before") 173 | variables["before"] = before 174 | if after: 175 | filter_list.append(".username < $after") 176 | variables["after"] = after 177 | if filter_list: 178 | filter_s = f'filter {" and ".join(filter_list)} ' 179 | else: 180 | filter_s = "" 181 | qb.add_variables(variables, replace=False) 182 | s, v = qb.build() 183 | q = f""" 184 | with 185 | all_accounts := (select Account), 186 | _first := $first, 187 | accounts := (select all_accounts {filter_s}order by .username desc limit _first), 188 | select {{ 189 | total_count := count(all_accounts), 190 | accounts := accounts {s} 191 | }} 192 | """ 193 | connection_d = await query_required_single_json( 194 | name="account_connection", query=q, **v 195 | ) 196 | total_count = connection_d["total_count"] 197 | _accounts = [Account(**d) for d in connection_d["accounts"]] 198 | connection = AccountConnection( 199 | page_info=AccountPageInfo( 200 | has_next_page=len(_accounts) == first and total_count > first, 201 | has_previous_page=after is not None, 202 | start_cursor=_accounts[0].username if _accounts else None, 203 | end_cursor=_accounts[-1].username if _accounts else None, 204 | ), 205 | total_count=total_count, 206 | edges=[ 207 | AccountEdge(node=account, cursor=account.username) 208 | for account in _accounts 209 | ], 210 | ) 211 | return connection 212 | 213 | 214 | router = build_router(query_models=[Query]) 215 | 216 | app = FastAPI() 217 | 218 | app.include_router(router, prefix="/graphql") 219 | -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | # site_name: FastGQL 2 | # theme: 3 | # name: material 4 | 5 | # yaml-language-server: $schema=https://squidfunk.github.io/mkdocs-material/schema.json 6 | 7 | site_name: FastGQL 8 | site_url: https://jerber.github.io/fastgql/ 9 | site_description: FastGQL is a python GraphQL framework that is high performance, easy to learn and ready for production. 10 | theme: 11 | name: material 12 | palette: 13 | - scheme: default 14 | primary: custom 15 | accent: custom 16 | toggle: 17 | icon: material/lightbulb 18 | name: Switch to dark mode 19 | - scheme: slate 20 | primary: custom 21 | accent: custom 22 | toggle: 23 | icon: material/lightbulb-outline 24 | name: Switch to light mode 25 | features: 26 | # FROM FASTAPI 27 | - search.suggest 28 | - search.highlight 29 | - navigation.instant 30 | - content.tabs.link 31 | - navigation.indexes 32 | - content.tooltips 33 | # - navigation.path 34 | - content.code.annotate 35 | - content.code.copy 36 | - content.code.select 37 | 38 | 39 | # FROM TUTORIAL 40 | # - navigation.tabs 41 | # - navigation.sections 42 | # - toc.integrate 43 | # - navigation.top 44 | # - search.suggest 45 | # - search.highlight 46 | # - content.tabs.link 47 | # - content.code.annotation 48 | # - content.code.copy 49 | icon: 50 | repo: fontawesome/brands/github-alt 51 | logo: 'img/emoji_logo.svg' 52 | favicon: 'img/emoji_logo.svg' 53 | language: en 54 | repo_name: 'jerber/fastgql' 55 | repo_url: 'https://github.com/jerber/fastgql' 56 | edit_uri: '' 57 | nav: 58 | - FastGQL: index.md 59 | - Tutorial - User Guide: 60 | - tutorial/index.md 61 | - tutorial/more_advanced.md 62 | - tutorial/query_builder.md 63 | 64 | plugins: 65 | - social 66 | - search 67 | 68 | extra: 69 | social: 70 | - icon: fontawesome/brands/github-alt 71 | link: https://github.com/jerber 72 | - icon: fontawesome/brands/twitter 73 | link: https://twitter.com/jerber888 74 | - icon: fontawesome/brands/linkedin 75 | link: https://www.linkedin.com/in/jeremyberman1/ 76 | 77 | # markdown_extensions: 78 | # - toc: 79 | # permalink: true 80 | # - pymdownx.superfences: 81 | # custom_fences: 82 | # - name: mermaid 83 | # class: mermaid 84 | # format: !!python/name:pymdownx.superfences.fence_code_format '' 85 | # - pymdownx.tabbed: 86 | # alternate_style: true 87 | 88 | # markdown_extensions: 89 | # - pymdownx.snippets: 90 | # base_path: ['docs'] 91 | # check_paths: true 92 | 93 | markdown_extensions: 94 | - pymdownx.highlight: 95 | anchor_linenums: true 96 | line_spans: __span 97 | pygments_lang_class: true 98 | - pymdownx.inlinehilite 99 | - admonition 100 | - pymdownx.arithmatex: 101 | generic: true 102 | - footnotes 103 | - pymdownx.details 104 | - pymdownx.superfences 105 | - pymdownx.tabbed: 106 | alternate_style: true 107 | - attr_list 108 | - abbr 109 | - pymdownx.snippets 110 | - pymdownx.emoji: 111 | emoji_index: !!python/name:material.extensions.emoji.twemoji 112 | emoji_generator: !!python/name:materialx.emoji.to_svg 113 | - mdx_include 114 | 115 | # this is SQLMODEL 116 | # markdown_extensions: 117 | # - toc: 118 | # permalink: true 119 | # - markdown.extensions.codehilite: 120 | # guess_lang: false 121 | # - admonition 122 | # - codehilite 123 | # - extra 124 | # - pymdownx.superfences: 125 | # custom_fences: 126 | # - name: mermaid 127 | # class: mermaid 128 | # format: !!python/name:pymdownx.superfences.fence_code_format '' 129 | # - pymdownx.tabbed: 130 | # alternate_style: true 131 | # - mdx_include 132 | 133 | extra_css: 134 | - stylesheets/extra.css 135 | -------------------------------------------------------------------------------- /docs/old/fastgql_old.md: -------------------------------------------------------------------------------- 1 | # FastGQL OLD 2 | 3 | FastGQL is a python GraphQL library that uses Pydantic models to build GraphQL types. Think FastAPI for GraphQL. 4 | 5 | ```py title="Pydantic Example" requires="3.10" 6 | from fastapi import FastAPI 7 | from fastgql import GQL, build_router 8 | 9 | class User(GQL): 10 | name: str 11 | age: int 12 | 13 | class Query(GQL): 14 | @staticmethod 15 | def user() -> User: 16 | return User(name="Jeremy", age=27) 17 | 18 | router = build_router(query_models=[Query]) 19 | 20 | app = FastAPI() 21 | 22 | app.include_router(router, prefix="/graphql") 23 | ``` 24 | 25 | SQLModel is a library for interacting with SQL databases from Python code, with Python objects. It is designed to be intuitive, easy to use, highly compatible, and robust. 26 | 27 | **SQLModel** is based on Python type annotations, and powered by Pydantic and SQLAlchemy. 28 | 29 | The key features are: 30 | 31 | - **Intuitive to write**: Great editor support. Completion everywhere. Less time debugging. Designed to be easy to use and learn. Less time reading docs. 32 | - **Easy to use**: It has sensible defaults and does a lot of work underneath to simplify the code you write. 33 | - **Compatible**: It is designed to be compatible with **FastAPI**, Pydantic, and SQLAlchemy. 34 | - **Extensible**: You have all the power of SQLAlchemy and Pydantic underneath. 35 | - **Short**: Minimize code duplication. A single type annotation does a lot of work. No need to duplicate models in SQLAlchemy and Pydantic. 36 | 37 | ## SQL Databases in FastAPI 38 | 39 | 40 | 41 | **SQLModel** is designed to simplify interacting with SQL databases in FastAPI applications, it was created by the same author. 😁 42 | 43 | It combines SQLAlchemy and Pydantic and tries to simplify the code you write as much as possible, allowing you to reduce the **code duplication to a minimum**, but while getting the **best developer experience** possible. 44 | 45 | **SQLModel** is, in fact, a thin layer on top of **Pydantic** and **SQLAlchemy**, carefully designed to be compatible with both. 46 | 47 | ## Requirements 48 | 49 | A recent and currently supported version of Python. 50 | 51 | As **SQLModel** is based on **Pydantic** and **SQLAlchemy**, it requires them. They will be automatically installed when you install SQLModel. 52 | 53 | ## Installation 54 | 55 |
56 | 57 | ```console 58 | $ pip install sqlmodel 59 | ---> 100% 60 | Successfully installed sqlmodel 61 | ``` 62 | 63 |
64 | 65 | ## Example 66 | 67 | For an introduction to databases, SQL, and everything else, see the SQLModel documentation. 68 | 69 | Here's a quick example. ✨ 70 | 71 | ### A SQL Table 72 | 73 | Imagine you have a SQL table called `hero` with: 74 | 75 | - `id` 76 | - `name` 77 | - `secret_name` 78 | - `age` 79 | 80 | And you want it to have this data: 81 | 82 | | id | name | secret_name | age | 83 | | --- | ---------- | ---------------- | ---- | 84 | | 1 | Deadpond | Dive Wilson | null | 85 | | 2 | Spider-Boy | Pedro Parqueador | null | 86 | | 3 | Rusty-Man | Tommy Sharp | 48 | 87 | 88 | ### Create a SQLModel Model 89 | 90 | Then you could create a **SQLModel** model like this: 91 | 92 | ```Python 93 | from typing import Optional 94 | 95 | from sqlmodel import Field, SQLModel 96 | 97 | 98 | class Hero(SQLModel, table=True): 99 | id: Optional[int] = Field(default=None, primary_key=True) 100 | name: str 101 | secret_name: str 102 | age: Optional[int] = None 103 | ``` 104 | 105 | That class `Hero` is a **SQLModel** model, the equivalent of a SQL table in Python code. 106 | 107 | And each of those class attributes is equivalent to each **table column**. 108 | 109 | ### Create Rows 110 | 111 | Then you could **create each row** of the table as an **instance** of the model: 112 | 113 | ```Python 114 | hero_1 = Hero(name="Deadpond", secret_name="Dive Wilson") 115 | hero_2 = Hero(name="Spider-Boy", secret_name="Pedro Parqueador") 116 | hero_3 = Hero(name="Rusty-Man", secret_name="Tommy Sharp", age=48) 117 | ``` 118 | 119 | This way, you can use conventional Python code with **classes** and **instances** that represent **tables** and **rows**, and that way communicate with the **SQL database**. 120 | 121 | ### Editor Support 122 | 123 | Everything is designed for you to get the best developer experience possible, with the best editor support. 124 | 125 | Including **autocompletion**: 126 | 127 | 128 | 129 | And **inline errors**: 130 | 131 | 132 | 133 | ### Write to the Database 134 | 135 | You can learn a lot more about **SQLModel** by quickly following the **tutorial**, but if you need a taste right now of how to put all that together and save to the database, you can do this: 136 | 137 | ```Python hl_lines="18 21 23-27" 138 | from typing import Optional 139 | 140 | from sqlmodel import Field, Session, SQLModel, create_engine 141 | 142 | 143 | class Hero(SQLModel, table=True): 144 | id: Optional[int] = Field(default=None, primary_key=True) 145 | name: str 146 | secret_name: str 147 | age: Optional[int] = None 148 | 149 | 150 | hero_1 = Hero(name="Deadpond", secret_name="Dive Wilson") 151 | hero_2 = Hero(name="Spider-Boy", secret_name="Pedro Parqueador") 152 | hero_3 = Hero(name="Rusty-Man", secret_name="Tommy Sharp", age=48) 153 | 154 | 155 | engine = create_engine("sqlite:///database.db") 156 | 157 | 158 | SQLModel.metadata.create_all(engine) 159 | 160 | with Session(engine) as session: 161 | session.add(hero_1) 162 | session.add(hero_2) 163 | session.add(hero_3) 164 | session.commit() 165 | ``` 166 | 167 | That will save a **SQLite** database with the 3 heroes. 168 | 169 | ### Select from the Database 170 | 171 | Then you could write queries to select from that same database, for example with: 172 | 173 | ```Python hl_lines="15-18" 174 | from typing import Optional 175 | 176 | from sqlmodel import Field, Session, SQLModel, create_engine, select 177 | 178 | 179 | class Hero(SQLModel, table=True): 180 | id: Optional[int] = Field(default=None, primary_key=True) 181 | name: str 182 | secret_name: str 183 | age: Optional[int] = None 184 | 185 | 186 | engine = create_engine("sqlite:///database.db") 187 | 188 | with Session(engine) as session: 189 | statement = select(Hero).where(Hero.name == "Spider-Boy") 190 | hero = session.exec(statement).first() 191 | print(hero) 192 | ``` 193 | 194 | ### Editor Support Everywhere 195 | 196 | **SQLModel** was carefully designed to give you the best developer experience and editor support, **even after selecting data** from the database: 197 | 198 | 199 | 200 | ## SQLAlchemy and Pydantic 201 | 202 | That class `Hero` is a **SQLModel** model. 203 | 204 | But at the same time, ✨ it is a **SQLAlchemy** model ✨. So, you can combine it and use it with other SQLAlchemy models, or you could easily migrate applications with SQLAlchemy to **SQLModel**. 205 | 206 | And at the same time, ✨ it is also a **Pydantic** model ✨. You can use inheritance with it to define all your **data models** while avoiding code duplication. That makes it very easy to use with **FastAPI**. 207 | 208 | ## License 209 | 210 | This project is licensed under the terms of the [MIT license](https://github.com/tiangolo/sqlmodel/blob/main/LICENSE). 211 | -------------------------------------------------------------------------------- /docs/old/index.md: -------------------------------------------------------------------------------- 1 | # Homepage 2 | 3 | FastGQL is a modern, fast (high-performance), web framework for building GraphQL APIs with Python 3.11+ based on standard Python type hints. 4 | 5 | The key features are: 6 | 7 | - **Fast**: Very high performance, on par with **NodeJS** and **Go** (thanks to Starlette and Pydantic). [One of the fastest Python frameworks available](#performance). 8 | - **Fast to code**: Increase the speed to develop features by about 200% to 300%. \* 9 | - **Fewer bugs**: Reduce about 40% of human (developer) induced errors. \* 10 | - **Intuitive**: Great editor support. Completion everywhere. Less time debugging. 11 | - **Easy**: Designed to be easy to use and learn. Less time reading docs. 12 | - **Short**: Minimize code duplication. Multiple features from each parameter declaration. Fewer bugs. 13 | - **Robust**: Get production-ready code. With automatic interactive documentation -- via graphiql. 14 | 15 | \* estimation based on tests on an internal development team, building production applications. 16 | 17 | ## Installation 18 | 19 |
20 | 21 | ```console 22 | $ pip install fastgql 23 | 24 | ---> 100% 25 | ``` 26 | 27 |
28 | 29 | You will also need an ASGI server, for production such as Uvicorn or Hypercorn. 30 | 31 |
32 | 33 | ```console 34 | $ pip install "uvicorn[standard]" 35 | 36 | ---> 100% 37 | ``` 38 | 39 |
40 | 41 | ## Example 42 | 43 | ### Create it 44 | 45 | - Create a file `main.py` with: 46 | 47 | ```Python 48 | from typing import Union 49 | 50 | from fastapi import FastAPI 51 | 52 | app = FastAPI() 53 | 54 | 55 | @app.get("/") 56 | def read_root(): 57 | return {"Hello": "World"} 58 | 59 | 60 | @app.get("/items/{item_id}") 61 | def read_item(item_id: int, q: Union[str, None] = None): 62 | return {"item_id": item_id, "q": q} 63 | ``` 64 | 65 |
66 | Or use async def... 67 | 68 | If your code uses `async` / `await`, use `async def`: 69 | 70 | ```Python hl_lines="9 14" 71 | from typing import Union 72 | 73 | from fastapi import FastAPI 74 | 75 | app = FastAPI() 76 | 77 | 78 | @app.get("/") 79 | async def read_root(): 80 | return {"Hello": "World"} 81 | 82 | 83 | @app.get("/items/{item_id}") 84 | async def read_item(item_id: int, q: Union[str, None] = None): 85 | return {"item_id": item_id, "q": q} 86 | ``` 87 | 88 | **Note**: 89 | 90 | If you don't know, check the _"In a hurry?"_ section about `async` and `await` in the docs. 91 | 92 |
93 | 94 | ### Run it 95 | 96 | Run the server with: 97 | 98 |
99 | 100 | ```console 101 | $ uvicorn main:app --reload 102 | 103 | INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) 104 | INFO: Started reloader process [28720] 105 | INFO: Started server process [28722] 106 | INFO: Waiting for application startup. 107 | INFO: Application startup complete. 108 | ``` 109 | 110 |
111 | 112 |
113 | About the command uvicorn main:app --reload... 114 | 115 | The command `uvicorn main:app` refers to: 116 | 117 | - `main`: the file `main.py` (the Python "module"). 118 | - `app`: the object created inside of `main.py` with the line `app = FastAPI()`. 119 | - `--reload`: make the server restart after code changes. Only do this for development. 120 | 121 |
122 | 123 | ### Check it 124 | 125 | Open your browser at http://127.0.0.1:8000/items/5?q=somequery. 126 | 127 | You will see the JSON response as: 128 | 129 | ```JSON 130 | {"item_id": 5, "q": "somequery"} 131 | ``` 132 | 133 | You already created an API that: 134 | 135 | - Receives HTTP requests in the _paths_ `/` and `/items/{item_id}`. 136 | - Both _paths_ take `GET` operations (also known as HTTP _methods_). 137 | - The _path_ `/items/{item_id}` has a _path parameter_ `item_id` that should be an `int`. 138 | - The _path_ `/items/{item_id}` has an optional `str` _query parameter_ `q`. 139 | 140 | ### Interactive API docs 141 | 142 | Now go to http://127.0.0.1:8000/docs. 143 | 144 | You will see the automatic interactive API documentation (provided by Swagger UI): 145 | 146 | ![Swagger UI](https://fastapi.tiangolo.com/img/index/index-01-swagger-ui-simple.png) 147 | 148 | ### Alternative API docs 149 | 150 | And now, go to http://127.0.0.1:8000/redoc. 151 | 152 | You will see the alternative automatic documentation (provided by ReDoc): 153 | 154 | ![ReDoc](https://fastapi.tiangolo.com/img/index/index-02-redoc-simple.png) 155 | 156 | ## Example upgrade 157 | 158 | Now modify the file `main.py` to receive a body from a `PUT` request. 159 | 160 | Declare the body using standard Python types, thanks to Pydantic. 161 | 162 | ```Python hl_lines="4 9-12 25-27" 163 | from typing import Union 164 | 165 | from fastapi import FastAPI 166 | from pydantic import BaseModel 167 | 168 | app = FastAPI() 169 | 170 | 171 | class Item(BaseModel): 172 | name: str 173 | price: float 174 | is_offer: Union[bool, None] = None 175 | 176 | 177 | @app.get("/") 178 | def read_root(): 179 | return {"Hello": "World"} 180 | 181 | 182 | @app.get("/items/{item_id}") 183 | def read_item(item_id: int, q: Union[str, None] = None): 184 | return {"item_id": item_id, "q": q} 185 | 186 | 187 | @app.put("/items/{item_id}") 188 | def update_item(item_id: int, item: Item): 189 | return {"item_name": item.name, "item_id": item_id} 190 | ``` 191 | 192 | The server should reload automatically (because you added `--reload` to the `uvicorn` command above). 193 | 194 | ### Interactive API docs upgrade 195 | 196 | Now go to http://127.0.0.1:8000/docs. 197 | 198 | - The interactive API documentation will be automatically updated, including the new body: 199 | 200 | ![Swagger UI](https://fastapi.tiangolo.com/img/index/index-03-swagger-02.png) 201 | 202 | - Click on the button "Try it out", it allows you to fill the parameters and directly interact with the API: 203 | 204 | ![Swagger UI interaction](https://fastapi.tiangolo.com/img/index/index-04-swagger-03.png) 205 | 206 | - Then click on the "Execute" button, the user interface will communicate with your API, send the parameters, get the results and show them on the screen: 207 | 208 | ![Swagger UI interaction](https://fastapi.tiangolo.com/img/index/index-05-swagger-04.png) 209 | 210 | ### Alternative API docs upgrade 211 | 212 | And now, go to http://127.0.0.1:8000/redoc. 213 | 214 | - The alternative documentation will also reflect the new query parameter and body: 215 | 216 | ![ReDoc](https://fastapi.tiangolo.com/img/index/index-06-redoc-02.png) 217 | 218 | ### Recap 219 | 220 | In summary, you declare **once** the types of parameters, body, etc. as function parameters. 221 | 222 | You do that with standard modern Python types. 223 | 224 | You don't have to learn a new syntax, the methods or classes of a specific library, etc. 225 | 226 | Just standard **Python 3.7+**. 227 | 228 | For example, for an `int`: 229 | 230 | ```Python 231 | item_id: int 232 | ``` 233 | 234 | or for a more complex `Item` model: 235 | 236 | ```Python 237 | item: Item 238 | ``` 239 | 240 | ...and with that single declaration you get: 241 | 242 | - Editor support, including: 243 | - Completion. 244 | - Type checks. 245 | - Validation of data: 246 | - Automatic and clear errors when the data is invalid. 247 | - Validation even for deeply nested JSON objects. 248 | - Conversion of input data: coming from the network to Python data and types. Reading from: 249 | - JSON. 250 | - Path parameters. 251 | - Query parameters. 252 | - Cookies. 253 | - Headers. 254 | - Forms. 255 | - Files. 256 | - Conversion of output data: converting from Python data and types to network data (as JSON): 257 | - Convert Python types (`str`, `int`, `float`, `bool`, `list`, etc). 258 | - `datetime` objects. 259 | - `UUID` objects. 260 | - Database models. 261 | - ...and many more. 262 | - Automatic interactive API documentation, including 2 alternative user interfaces: 263 | - Swagger UI. 264 | - ReDoc. 265 | 266 | --- 267 | 268 | Coming back to the previous code example, **FastAPI** will: 269 | 270 | - Validate that there is an `item_id` in the path for `GET` and `PUT` requests. 271 | - Validate that the `item_id` is of type `int` for `GET` and `PUT` requests. 272 | - If it is not, the client will see a useful, clear error. 273 | - Check if there is an optional query parameter named `q` (as in `http://127.0.0.1:8000/items/foo?q=somequery`) for `GET` requests. 274 | - As the `q` parameter is declared with `= None`, it is optional. 275 | - Without the `None` it would be required (as is the body in the case with `PUT`). 276 | - For `PUT` requests to `/items/{item_id}`, Read the body as JSON: 277 | - Check that it has a required attribute `name` that should be a `str`. 278 | - Check that it has a required attribute `price` that has to be a `float`. 279 | - Check that it has an optional attribute `is_offer`, that should be a `bool`, if present. 280 | - All this would also work for deeply nested JSON objects. 281 | - Convert from and to JSON automatically. 282 | - Document everything with OpenAPI, that can be used by: 283 | - Interactive documentation systems. 284 | - Automatic client code generation systems, for many languages. 285 | - Provide 2 interactive documentation web interfaces directly. 286 | 287 | --- 288 | 289 | We just scratched the surface, but you already get the idea of how it all works. 290 | 291 | Try changing the line with: 292 | 293 | ```Python 294 | return {"item_name": item.name, "item_id": item_id} 295 | ``` 296 | 297 | ...from: 298 | 299 | ```Python 300 | ... "item_name": item.name ... 301 | ``` 302 | 303 | ...to: 304 | 305 | ```Python 306 | ... "item_price": item.price ... 307 | ``` 308 | 309 | ...and see how your editor will auto-complete the attributes and know their types: 310 | 311 | ![editor support](https://fastapi.tiangolo.com/img/vscode-completion.png) 312 | 313 | For a more complete example including more features, see the Tutorial - User Guide. 314 | 315 | **Spoiler alert**: the tutorial - user guide includes: 316 | 317 | - Declaration of **parameters** from other different places as: **headers**, **cookies**, **form fields** and **files**. 318 | - How to set **validation constraints** as `maximum_length` or `regex`. 319 | - A very powerful and easy to use **Dependency Injection** system. 320 | - Security and authentication, including support for **OAuth2** with **JWT tokens** and **HTTP Basic** auth. 321 | - More advanced (but equally easy) techniques for declaring **deeply nested JSON models** (thanks to Pydantic). 322 | - **GraphQL** integration with Strawberry and other libraries. 323 | - Many extra features (thanks to Starlette) as: 324 | - **WebSockets** 325 | - extremely easy tests based on HTTPX and `pytest` 326 | - **CORS** 327 | - **Cookie Sessions** 328 | - ...and more. 329 | 330 | ## Performance 331 | 332 | Independent TechEmpower benchmarks show **FastAPI** applications running under Uvicorn as one of the fastest Python frameworks available, only below Starlette and Uvicorn themselves (used internally by FastAPI). (\*) 333 | 334 | To understand more about it, see the section Benchmarks. 335 | 336 | ## Optional Dependencies 337 | 338 | Used by Pydantic: 339 | 340 | - email_validator - for email validation. 341 | - pydantic-settings - for settings management. 342 | - pydantic-extra-types - for extra types to be used with Pydantic. 343 | 344 | Used by Starlette: 345 | 346 | - httpx - Required if you want to use the `TestClient`. 347 | - jinja2 - Required if you want to use the default template configuration. 348 | - python-multipart - Required if you want to support form "parsing", with `request.form()`. 349 | - itsdangerous - Required for `SessionMiddleware` support. 350 | - pyyaml - Required for Starlette's `SchemaGenerator` support (you probably don't need it with FastAPI). 351 | - ujson - Required if you want to use `UJSONResponse`. 352 | 353 | Used by FastAPI / Starlette: 354 | 355 | - uvicorn - for the server that loads and serves your application. 356 | - orjson - Required if you want to use `ORJSONResponse`. 357 | 358 | You can install all of these with `pip install "fastapi[all]"`. 359 | 360 | ## License 361 | 362 | This project is licensed under the terms of the MIT license. 363 | -------------------------------------------------------------------------------- /docs/old/index_old.md: -------------------------------------------------------------------------------- 1 | # Welcome to MkDocs 2 | 3 | For full documentation visit [mkdocs.org](https://www.mkdocs.org). 4 | 5 | ## Commands 6 | 7 | * `mkdocs new [dir-name]` - Create a new project. 8 | * `mkdocs serve` - Start the live-reloading docs server. 9 | * `mkdocs build` - Build the documentation site. 10 | * `mkdocs -h` - Print help message and exit. 11 | 12 | ## Project layout 13 | 14 | mkdocs.yml # The configuration file. 15 | docs/ 16 | index.md # The documentation homepage. 17 | ... # Other markdown pages, images and other files. 18 | -------------------------------------------------------------------------------- /docs/old/tutorial_index_old.md: -------------------------------------------------------------------------------- 1 | # Intro, Installation, and First Steps 2 | 3 | ## Many of these sections were taken from the [SQLModel docs](https://sqlmodel.tiangolo.com/tutorial/), which are very good at explaining Python concepts. I'm including them here to make things easier. 4 | 5 | ## Type hints (From SQLModel) 6 | 7 | If you need a refresher about how to use Python type hints (type annotations), check FastAPI's Python types intro. 8 | 9 | You can also check the mypy cheat sheet. 10 | 11 | **FastGQL** uses type annotations for everything, this way you can use a familiar Python syntax and get all the editor support possible, with autocompletion and in-editor error checking. 12 | 13 | ## Create a Project (from SQLModel) 14 | 15 | Please go ahead and create a directory for the project we will work on on this tutorial. 16 | 17 | What I normally do is that I create a directory named `code` inside my home/user directory. 18 | 19 | And inside of that I create one directory per project. 20 | 21 | So, for example: 22 | 23 |
24 | 25 | ```console 26 | // Go to the home directory 27 | $ cd 28 | // Create a directory for all your code projects 29 | $ mkdir code 30 | // Enter into that code directory 31 | $ cd code 32 | // Create a directory for this project 33 | $ mkdir fastgql-tutorial 34 | // Enter into that directory 35 | $ cd fastgql-tutorial 36 | ``` 37 | 38 |
39 | 40 | Make sure you don't name it also `fastgql`, so that you don't end up overriding the name of the package. 41 | 42 | ### Make sure you have Python 43 | 44 | Make sure you have an officially supported version of Python. 45 | 46 | You can check which version you have with: 47 | 48 |
49 | 50 | ```console 51 | $ python3 --version 52 | Python 3.12 53 | ``` 54 | 55 |
56 | 57 | For now, FastGQL only supports python 3.10 and up. 58 | 59 | If you don't have python 3.10 or up installed, go and install that first. 60 | 61 | ### Create a Python virtual environment (from SQLModel) 62 | 63 | When writing Python code, you should **always** use virtual environments in one way or another. 64 | 65 | If you don't know what that is, you can read the official tutorial for virtual environments, it's quite simple. 66 | 67 | In very short, a virtual environment is a small directory that contains a copy of Python and all the libraries you need to run your code. 68 | 69 | And when you "activate" it, any package that you install, for example with `pip`, will be installed in that virtual environment. 70 | 71 | !!! tip " There are other tools to manage virtual environments, like Poetry. " 72 | 73 | And there are alternatives that are particularly useful for deployment like Docker and other types of containers. In this case, the "virtual environment" is not just the Python standard files and the installed packages, but the whole system. 74 | 75 | Go ahead and create a Python virtual environment for this project. And make sure to also upgrade `pip`. 76 | 77 | Here are the commands you could use: 78 | 79 | === "Linux, macOS, Linux in Windows" 80 | 81 |
82 | 83 | ```console 84 | // Remember that you might need to use python3.9 or similar 💡 85 | // Create the virtual environment using the module "venv" 86 | $ python3 -m venv env 87 | // ...here it creates the virtual environment in the directory "env" 88 | // Activate the virtual environment 89 | $ source ./env/bin/activate 90 | // Verify that the virtual environment is active 91 | # (env) $$ which python 92 | // The important part is that it is inside the project directory, at "code/fastgql-tutorial/env/bin/python" 93 | /home/leela/code/fastgql-tutorial/env/bin/python 94 | // Use the module "pip" to install and upgrade the package "pip" 🤯 95 | # (env) $$ python -m pip install --upgrade pip 96 | ---> 100% 97 | Successfully installed pip 98 | ``` 99 | 100 |
101 | 102 | === "Windows PowerShell" 103 | 104 |
105 | 106 | ```console 107 | // Create the virtual environment using the module "venv" 108 | # >$ python3 -m venv env 109 | // ...here it creates the virtual environment in the directory "env" 110 | // Activate the virtual environment 111 | # >$ .\env\Scripts\Activate.ps1 112 | // Verify that the virtual environment is active 113 | # (env) >$ Get-Command python 114 | // The important part is that it is inside the project directory, at "code\fastgql-tutorial\env\python.exe" 115 | CommandType Name Version Source 116 | ----------- ---- ------- ------ 117 | Application python 0.0.0.0 C:\Users\leela\code\fastgql-tutorial\env\python.exe 118 | // Use the module "pip" to install and upgrade the package "pip" 🤯 119 | # (env) >$ python3 -m pip install --upgrade pip 120 | ---> 100% 121 | Successfully installed pip 122 | ``` 123 | 124 |
125 | 126 | ## Install **FastGQL** 127 | 128 | Now, after making sure we are inside of a virtual environment in some way, we can install **FastGQL**: 129 | 130 |
131 | 132 | ```console 133 | # (env) $$ python -m pip install fastgql 134 | ---> 100% 135 | Successfully installed fastgql 136 | ``` 137 | 138 |
139 | -------------------------------------------------------------------------------- /fastgql/__init__.py: -------------------------------------------------------------------------------- 1 | from .gql_models import GQL, GQLInput, GQLError, GQLConfigDict, GQLInterface 2 | from .schema_builder import SchemaBuilder 3 | from .info import Info 4 | from .context import BaseContext 5 | from .depends import Depends 6 | from .query_builders.edgedb.logic import get_qb 7 | from .query_builders.edgedb.query_builder import QueryBuilder, ChildEdge 8 | from .query_builders.edgedb.config import Link, Property, QueryBuilderConfig 9 | from .gql_ast.models import ( 10 | Node, 11 | FieldNode, 12 | FieldNodeModel, 13 | FieldNodeField, 14 | FieldNodeMethod, 15 | OperationNode, 16 | ) 17 | from .utils import node_from_path 18 | 19 | build_router = SchemaBuilder.build_router 20 | 21 | __all__ = [ 22 | "SchemaBuilder", 23 | "build_router", 24 | "GQL", 25 | "GQLInterface", 26 | "GQLInput", 27 | "GQLError", 28 | "GQLConfigDict", 29 | "Info", 30 | "BaseContext", 31 | "Depends", 32 | "get_qb", 33 | "QueryBuilder", 34 | "ChildEdge", 35 | "Link", 36 | "Property", 37 | "QueryBuilderConfig", 38 | "Node", 39 | "FieldNode", 40 | "FieldNodeModel", 41 | "FieldNodeField", 42 | "FieldNodeMethod", 43 | "OperationNode", 44 | "node_from_path", 45 | ] 46 | -------------------------------------------------------------------------------- /fastgql/context.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | from fastapi import Request, Response, BackgroundTasks 3 | from fastgql import GQLError 4 | from fastgql.gql_ast.models import Node 5 | 6 | 7 | class BaseContext: 8 | def __init__( 9 | self, 10 | request: Request, 11 | response: Response, 12 | background_tasks: BackgroundTasks, 13 | errors: list[GQLError], 14 | variables: dict[str, T.Any], 15 | ): 16 | self.request = request 17 | self.response = response 18 | self.background_tasks = background_tasks 19 | self.errors = errors 20 | self.variables = variables 21 | 22 | self.overwrite_return_value_map: dict[Node, T.Any] = {} 23 | -------------------------------------------------------------------------------- /fastgql/depends.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | 3 | 4 | class Depends: 5 | def __init__(self, dependency: T.Callable): 6 | self.dependency = dependency 7 | 8 | 9 | __all__ = ["Depends"] 10 | -------------------------------------------------------------------------------- /fastgql/execute/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerber/fastgql/064c1e846b1758a967d1464c50d7fef6b9f095b9/fastgql/execute/__init__.py -------------------------------------------------------------------------------- /fastgql/execute/executor.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | import time 3 | 4 | import graphql 5 | from fastapi import Request, Response, BackgroundTasks 6 | 7 | from fastgql.gql_ast import models as M 8 | from fastgql.gql_ast.translator import Translator 9 | from fastgql.gql_models import GQL, GQLError 10 | from fastgql.execute.utils import ( 11 | build_is_not_nullable_map, 12 | CacheDict, 13 | Result, 14 | InfoType, 15 | ContextType, 16 | gql_errors_to_graphql_errors, 17 | RESULT_WRAPPERS, 18 | ) 19 | from fastgql.execute.resolver import Resolver 20 | 21 | DISPLAY_TO_PYTHON_MAP: dict[str, str] = {} 22 | 23 | 24 | class Executor: 25 | """this class has the un-changing config""" 26 | 27 | def __init__( 28 | self, 29 | python_to_display_map: dict[str, str], 30 | schema: graphql.GraphQLSchema, 31 | query_model: GQL | None, 32 | mutation_model: GQL | None, 33 | root_nodes_cache_size: int = 100, 34 | process_errors: T.Optional[T.Callable[[list[GQLError]], list[GQLError]]] = None, 35 | result_wrappers: RESULT_WRAPPERS = None, 36 | ): 37 | self.python_to_display_map = python_to_display_map 38 | self.display_to_python_map = { 39 | v: k for k, v in self.python_to_display_map.items() 40 | } 41 | self.schema = schema 42 | self.is_not_nullable_map = build_is_not_nullable_map(schema) 43 | self.operation_type_to_model: dict[M.OperationType, GQL | None] = { 44 | M.OperationType.query: query_model, 45 | M.OperationType.mutation: mutation_model, 46 | } 47 | self.root_nodes_cache: dict[str, list[M.OperationNode]] = CacheDict( 48 | cache_len=root_nodes_cache_size 49 | ) 50 | self.process_errors = process_errors 51 | self.result_wrappers = result_wrappers 52 | 53 | DISPLAY_TO_PYTHON_MAP.update(self.display_to_python_map) 54 | 55 | async def execute( 56 | self, 57 | *, 58 | source: str, 59 | variable_values: dict[str, T.Any] | None, 60 | operation_name: str | None, 61 | validate_schema: bool = True, 62 | validate_query: bool = True, 63 | validate_variables: bool = True, 64 | info_cls: T.Type[InfoType], 65 | context_cls: T.Type[ContextType], 66 | request: Request, 67 | response: Response, 68 | bt: BackgroundTasks, 69 | use_cache: bool, 70 | print_timings: bool = False, 71 | ) -> Result: 72 | start_for_root_nodes = time.time() 73 | if use_cache: 74 | root_nodes = self.root_nodes_cache.get(source) 75 | else: 76 | root_nodes = None 77 | if not root_nodes: 78 | if validate_schema: 79 | schema_validation_errors = graphql.validate_schema(self.schema) 80 | if schema_validation_errors: 81 | return Result( 82 | data=None, errors=schema_validation_errors, extensions=None 83 | ) 84 | try: 85 | document = graphql.parse(source) 86 | except graphql.GraphQLError as error: 87 | return Result(data=None, errors=[error], extensions=None) 88 | if validate_query: 89 | validation_errors = graphql.validation.validate(self.schema, document) 90 | if validation_errors: 91 | return Result(data=None, errors=validation_errors, extensions=None) 92 | if validate_variables: 93 | from graphql.execution.execute import assert_valid_execution_arguments 94 | 95 | assert_valid_execution_arguments(self.schema, document, variable_values) 96 | 97 | start_translate = time.time() 98 | root_nodes = Translator( 99 | document=document, 100 | schema=self.schema, 101 | display_to_python_map=self.display_to_python_map, 102 | ).translate() 103 | if print_timings: 104 | print( 105 | f"[TRANSLATING] took {(time.time() - start_translate) * 1_000} ms" 106 | ) 107 | if use_cache: 108 | self.root_nodes_cache[source] = root_nodes 109 | if print_timings: 110 | print( 111 | f"[ROOT NODES] parsing took {(time.time() - start_for_root_nodes) * 1_000} ms" 112 | ) 113 | resolver = Resolver( 114 | operation_name=operation_name, 115 | display_to_python_map=self.display_to_python_map, 116 | info_cls=info_cls, 117 | context_cls=context_cls, 118 | is_not_nullable_map=self.is_not_nullable_map, 119 | variables=variable_values, 120 | request=request, 121 | response=response, 122 | bt=bt, 123 | ) 124 | d = await resolver.resolve_root_nodes( 125 | root_nodes=root_nodes, operation_type_to_model=self.operation_type_to_model 126 | ) 127 | # now process errors 128 | if self.process_errors: 129 | errors = self.process_errors(resolver.errors) 130 | else: 131 | errors = resolver.errors 132 | return Result( 133 | data=d, errors=gql_errors_to_graphql_errors(errors), extensions=None 134 | ) 135 | -------------------------------------------------------------------------------- /fastgql/execute/resolver.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | import traceback 3 | import asyncio 4 | import inspect 5 | 6 | from fastapi import Request, Response, BackgroundTasks 7 | from pydantic import TypeAdapter, ValidationError 8 | 9 | from fastgql.gql_ast import models as M 10 | from fastgql.gql_models import GQL, GQLError 11 | from fastgql.depends import Depends 12 | from fastgql.execute.utils import InfoType, parse_value, Info, ContextType 13 | from fastgql.utils import node_from_path 14 | import pydantic_core 15 | 16 | 17 | class Resolver: 18 | def __init__( 19 | self, 20 | *, 21 | operation_name: str | None, 22 | display_to_python_map: dict[str, str], 23 | info_cls: T.Type[InfoType], 24 | context_cls: T.Type[ContextType], 25 | is_not_nullable_map: dict[str, dict[str, bool]], 26 | variables: dict[str, T.Any] | None, 27 | request: Request, 28 | response: Response, 29 | bt: BackgroundTasks, 30 | ): 31 | self.operation_name = operation_name 32 | self.display_to_python_map = display_to_python_map 33 | self.info_cls = info_cls 34 | self.is_not_nullable_map = is_not_nullable_map 35 | self.variables = variables 36 | 37 | self.request = request 38 | self.response = response 39 | self.bt = bt 40 | 41 | self.errors: list[GQLError] = [] 42 | 43 | self.context: ContextType = context_cls( 44 | request=self.request, 45 | response=self.response, 46 | background_tasks=self.bt, 47 | errors=self.errors, 48 | variables=self.variables, 49 | ) 50 | 51 | async def inject_dependencies( 52 | self, func: T.Callable[..., T.Any], kwargs: dict[str, T.Any], info: InfoType 53 | ) -> T.Any: 54 | # if you need to build kwargs, do it. Then execute 55 | new_kwargs = await self.build_kwargs(func=func, kwargs=kwargs, info=info) 56 | res = func(**new_kwargs) 57 | if inspect.isawaitable(res): 58 | res = await res 59 | return res 60 | 61 | async def build_kwargs( 62 | self, func: T.Callable[..., T.Any], kwargs: dict[str, T.Any], info: InfoType 63 | ) -> dict[str, T.Any]: 64 | new_kwargs: dict[str, T.Any] = {} 65 | sig = inspect.signature(func) 66 | for name, param in sig.parameters.items(): 67 | if name in kwargs: 68 | val = kwargs[name] 69 | if val is not None: 70 | try: 71 | val = TypeAdapter(param.annotation).validate_python( 72 | val, 73 | context={ 74 | "_display_to_python_map": self.display_to_python_map 75 | }, 76 | ) 77 | except ValidationError as e: 78 | if e.errors()[0]["type"] == "list_type" and not isinstance( 79 | val, list 80 | ): 81 | val = TypeAdapter(param.annotation).validate_python( 82 | [val], 83 | context={ 84 | "_display_to_python_map": self.display_to_python_map 85 | }, 86 | ) 87 | else: 88 | raise e 89 | new_kwargs[name] = val 90 | else: 91 | if isinstance(param.default, Depends): 92 | new_kwargs[name] = await self.inject_dependencies( 93 | func=param.default.dependency, kwargs=kwargs, info=info 94 | ) 95 | elif inspect.isclass(param.annotation): 96 | if issubclass(param.annotation, Info): 97 | new_kwargs[name] = info 98 | elif issubclass(param.annotation, Request): 99 | new_kwargs[name] = self.request 100 | elif issubclass(param.annotation, Response): 101 | new_kwargs[name] = self.response 102 | elif issubclass(param.annotation, BackgroundTasks): 103 | new_kwargs[name] = self.bt 104 | # just continue, the value is not given and that is okay 105 | return new_kwargs 106 | 107 | def get_info_key(self, func: T.Callable[..., T.Any]) -> str | None: 108 | sig = inspect.signature(func) 109 | for name, param in sig.parameters.items(): 110 | if param.annotation == self.info_cls: 111 | return name 112 | 113 | async def inject_dependencies_and_execute( 114 | self, 115 | node: M.FieldNode, 116 | parent: M.Node, 117 | method: T.Callable | T.Awaitable, 118 | kwargs: dict[str, T.Any], 119 | new_path: tuple[str, ...], 120 | ) -> dict[str, T.Any] | list[dict[str, T.Any]] | T.Any | None: 121 | # TODO if inefficient, lazily create info! Or maybe even cache it... or come up with a better way 122 | info = self.info_cls( 123 | node=node, 124 | parent_node=parent, 125 | path=new_path, 126 | context=self.context, 127 | ) 128 | new_kwargs = await self.build_kwargs(func=method, kwargs=kwargs, info=info) 129 | child_model_s = method(**new_kwargs) 130 | if inspect.isawaitable(child_model_s): 131 | child_model_s = await child_model_s 132 | if child_model_s: 133 | if node.children: 134 | return await self.resolve_node_s( 135 | node=node, model_s=child_model_s, path=new_path 136 | ) 137 | else: 138 | return child_model_s 139 | return child_model_s 140 | 141 | async def resolve_node_s( 142 | self, 143 | node: M.FieldNode | M.OperationNode, 144 | model_s: list[GQL] | GQL, 145 | path: tuple[str, ...], 146 | ) -> dict[str, T.Any] | None | list[dict[str, T.Any] | None]: 147 | if isinstance(model_s, list): 148 | return list( 149 | await asyncio.gather( 150 | *[ 151 | self.resolve_node(node=node, model=model, path=path) 152 | for model in model_s 153 | ] 154 | ) 155 | ) 156 | return await self.resolve_node(node=node, model=model_s, path=path) 157 | 158 | async def resolve_node( 159 | self, 160 | node: M.FieldNode | M.OperationNode, 161 | model: GQL, 162 | path: tuple[str, ...], 163 | ) -> dict[str, T.Any] | None: 164 | if model is None: 165 | return None 166 | if not isinstance(model, GQL): 167 | raise Exception( 168 | f"Model {model.__class__.__name__} must be an instance of GQL." 169 | ) 170 | # return final dict, or array of dicts, for the node 171 | final_d: dict[str, T.Any] = {} 172 | fields_to_include: dict[str, str] = {} 173 | name_to_return_to_display_name: dict[str, str] = {} 174 | children_q = [*node.children] 175 | proms_map: dict[str, T.Awaitable] = {} 176 | while len(children_q) > 0: 177 | child = children_q.pop(0) 178 | if isinstance(child, M.InlineFragmentNode): 179 | if child.type_condition == model.gql_type_name(): 180 | children_q[:0] = child.children 181 | continue 182 | name_to_return = child.alias or child.display_name 183 | name_to_return_to_display_name[name_to_return] = child.display_name 184 | if child in self.context.overwrite_return_value_map: 185 | final_d[name_to_return] = self.context.overwrite_return_value_map[child] 186 | continue 187 | new_path = (*path, name_to_return) 188 | if child.name == "__typename": 189 | final_d[name_to_return] = model.gql_type_name() 190 | else: 191 | if child.name not in model.model_fields: 192 | # this must be a function 193 | 194 | kwargs = {} 195 | for arg in child.arguments: 196 | _v = parse_value(variables=self.variables, v=arg.value) 197 | if _v != pydantic_core.PydanticUndefined: 198 | kwargs[arg.name] = _v 199 | 200 | proms_map[name_to_return] = self.inject_dependencies_and_execute( 201 | method=getattr(model, child.name), 202 | node=child, 203 | parent=node, 204 | kwargs=kwargs, 205 | new_path=new_path, 206 | ) 207 | else: 208 | if not child.children: 209 | # this is a property 210 | fields_to_include[child.name] = name_to_return 211 | else: 212 | # this is either a BaseModel or a list of BaseModel 213 | proms_map[name_to_return] = self.resolve_node_s( 214 | node=child, 215 | path=new_path, 216 | model_s=getattr(model, child.name), 217 | ) 218 | 219 | # now gather the await-ables 220 | if proms_map: 221 | vals = await asyncio.gather(*proms_map.values(), return_exceptions=True) 222 | for name, val in zip(proms_map.keys(), vals): 223 | if isinstance(val, Exception): 224 | if isinstance(val, GQLError): 225 | self.errors.append(val) 226 | else: 227 | # do not expose this error to client 228 | # include stack trace and send to sentry 229 | node_that_errored = node_from_path( 230 | node=node, 231 | path=[name], 232 | use_field_to_use=True, 233 | ) 234 | self.errors.append( 235 | GQLError( 236 | message="Internal Server Error", 237 | node=node_that_errored, 238 | original_error=val, 239 | path=(*path, name), 240 | ) 241 | ) 242 | traceback.print_exception(val) 243 | val = None 244 | final_d[name] = val 245 | model_d = model.model_dump(mode="json", include=set(fields_to_include)) 246 | for name, name_to_use in fields_to_include.items(): 247 | final_d[name_to_use] = model_d[name] 248 | if name_to_use != name: 249 | final_d[name] = model_d[name] 250 | 251 | # now null check and order property 252 | sorted_d = {} 253 | for name_to_return, name in name_to_return_to_display_name.items(): 254 | val = final_d[name_to_return] 255 | if ( 256 | val is None or isinstance(val, list) and None in val 257 | ): # TODO speed test, must go thru every list? 258 | if self.is_not_nullable_map[model.gql_type_name()][name]: 259 | # get the actual node from the path 260 | null_node = node_from_path( 261 | node=node, path=[name_to_return], use_field_to_use=True 262 | ) 263 | if ( 264 | null_node not in self.context.overwrite_return_value_map 265 | and path 266 | ): 267 | full_path_to_error = (*path, name_to_return) 268 | self.errors.append( 269 | GQLError( 270 | message=f"Cannot return null for non-nullable field {'.'.join(full_path_to_error)}", 271 | path=full_path_to_error, 272 | node=null_node, 273 | ) 274 | ) 275 | if node not in self.context.overwrite_return_value_map: 276 | self.context.overwrite_return_value_map[node] = None 277 | return None 278 | sorted_d[name_to_return] = val 279 | del final_d 280 | return sorted_d 281 | 282 | async def resolve_root_nodes( 283 | self, 284 | root_nodes: list[M.OperationNode], 285 | operation_type_to_model: dict[M.OperationType, GQL | None], 286 | ) -> dict[str, T.Any] | None: 287 | proms = [] 288 | for root_node in root_nodes: 289 | model = operation_type_to_model[root_node.type] 290 | if not model: 291 | raise Exception(f"{root_node.type} type required.") 292 | proms.append( 293 | self.resolve_node( 294 | node=root_node, 295 | model=operation_type_to_model[root_node.type], 296 | path=(), 297 | ) 298 | ) 299 | d_list = await asyncio.gather(*proms) 300 | final_d = {} 301 | for d in d_list: 302 | if d is None: 303 | return None 304 | final_d.update(d) 305 | return final_d 306 | -------------------------------------------------------------------------------- /fastgql/execute/utils.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | 3 | import pydantic_core 4 | from fastapi import Request, Response, BackgroundTasks 5 | from collections import OrderedDict 6 | from dataclasses import dataclass 7 | 8 | from pydantic import create_model 9 | import graphql 10 | 11 | from fastgql.info import Info, ContextType 12 | from fastgql.gql_models import GQL, GQLError 13 | 14 | InfoType = T.TypeVar("InfoType", bound=Info) 15 | 16 | 17 | @dataclass 18 | class GraphQLRequestData: 19 | # query is optional here as it can be added by an extensions 20 | # (for example an extension for persisted queries) 21 | query: str | None 22 | variables: dict[str, T.Any] | None 23 | operation_name: str | None 24 | 25 | 26 | @dataclass 27 | class Result: 28 | data: T.Any | None 29 | errors: list[graphql.GraphQLError] | None 30 | extensions: list[T.Any] | None 31 | 32 | 33 | RESULT_WRAPPERS = T.Optional[ 34 | T.List[ 35 | T.Callable[ 36 | [GraphQLRequestData, Result, dict[str, T.Any], Request, Response, BackgroundTasks], 37 | T.Coroutine[T.Any, T.Any, Result], 38 | ] 39 | ] 40 | ] 41 | 42 | 43 | def gql_errors_to_graphql_errors( 44 | gql_errors: list[GQLError], 45 | ) -> list[graphql.GraphQLError]: 46 | graphql_errors: list[graphql.GraphQLError] = [] 47 | for e in gql_errors: 48 | graphql_errors.append( 49 | graphql.GraphQLError( 50 | message=e.message, 51 | nodes=e.node.original_node if e.node else None, 52 | path=e.path, 53 | original_error=e.original_error, 54 | extensions=e.extensions, 55 | ) 56 | ) 57 | return graphql_errors 58 | 59 | 60 | class CacheDict(OrderedDict): 61 | """Dict with a limited length, ejecting LRUs as needed.""" 62 | 63 | def __init__(self, *args, cache_len: int, **kwargs): 64 | assert cache_len > 0 65 | self.cache_len = cache_len 66 | 67 | super().__init__(*args, **kwargs) 68 | 69 | def __setitem__(self, key, value): 70 | super().__setitem__(key, value) 71 | super().move_to_end(key) 72 | 73 | while len(self) > self.cache_len: 74 | oldkey = next(iter(self)) 75 | super().__delitem__(oldkey) 76 | 77 | def __getitem__(self, key): 78 | val = super().__getitem__(key) 79 | super().move_to_end(key) 80 | 81 | return val 82 | 83 | 84 | def combine_models(name: str, *models: T.Type[GQL]) -> T.Type[GQL]: 85 | combined_model = create_model(name, __base__=GQL) 86 | 87 | seen_fields = set() 88 | excluded_methods = set(dir(GQL)) 89 | 90 | for model in models: 91 | for field_name, field_value in model.model_fields.items(): 92 | if field_name in seen_fields: 93 | raise ValueError(f"Conflicting field: {field_name}") 94 | setattr(combined_model, field_name, field_value) 95 | seen_fields.add(field_name) 96 | 97 | for method_name in dir(model): 98 | if ( 99 | not method_name.startswith("_") 100 | and callable(getattr(model, method_name)) 101 | and method_name not in excluded_methods 102 | ): 103 | method = getattr(model, method_name) 104 | if isinstance(model.__dict__.get(method_name), staticmethod): 105 | method = staticmethod(method) 106 | elif isinstance(model.__dict__.get(method_name), classmethod): 107 | method = classmethod(method) 108 | if method_name in dir(combined_model): 109 | raise ValueError(f"Conflicting method: {method_name}") 110 | setattr(combined_model, method_name, method) 111 | 112 | combined_model.model_rebuild() 113 | return combined_model 114 | 115 | 116 | def parse_value(variables: dict[str, T.Any] | None, v: T.Any) -> T.Any: 117 | if isinstance(v, dict): 118 | return { 119 | k: parse_value(variables=variables, v=inner_v) for k, inner_v in v.items() 120 | } 121 | if isinstance(v, list): 122 | return [parse_value(variables=variables, v=inner_v) for inner_v in v] 123 | if isinstance(v, graphql.VariableNode): 124 | if v.name.value not in variables: 125 | return pydantic_core.PydanticUndefined 126 | return variables[v.name.value] 127 | return v 128 | 129 | 130 | def build_is_not_nullable_map( 131 | schema: graphql.GraphQLSchema, 132 | ) -> dict[str, dict[str, bool]]: 133 | is_not_nullable_map: dict[str, dict[str, bool]] = {} 134 | for type_name, gql_type in schema.type_map.items(): 135 | if isinstance(gql_type, graphql.GraphQLObjectType): 136 | is_not_nullable_map[type_name] = {} 137 | type_map = is_not_nullable_map[type_name] 138 | for field_name, field_val in gql_type.fields.items(): 139 | field_val: graphql.GraphQLField 140 | type_map[field_name] = isinstance( 141 | field_val.type, graphql.GraphQLNonNull 142 | ) 143 | return is_not_nullable_map 144 | 145 | 146 | def get_root_type( 147 | gql_field: graphql.GraphQLField | graphql.GraphQLObjectType, 148 | ) -> graphql.GraphQLObjectType | list[graphql.GraphQLObjectType]: 149 | if isinstance(gql_field, graphql.GraphQLObjectType): 150 | return gql_field 151 | t = gql_field.type 152 | while True: 153 | if isinstance(t, graphql.GraphQLUnionType): 154 | return [get_root_type(gql_field=tt) for tt in t.types] 155 | if isinstance(t, graphql.GraphQLObjectType): 156 | return t 157 | t = t.of_type 158 | 159 | 160 | __all__ = [ 161 | "InfoType", 162 | "ContextType", 163 | "get_root_type", 164 | "build_is_not_nullable_map", 165 | "parse_value", 166 | "combine_models", 167 | "Result", 168 | "gql_errors_to_graphql_errors", 169 | "CacheDict", 170 | "Info", 171 | "GraphQLRequestData", 172 | "RESULT_WRAPPERS", 173 | ] 174 | -------------------------------------------------------------------------------- /fastgql/gql_ast/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerber/fastgql/064c1e846b1758a967d1464c50d7fef6b9f095b9/fastgql/gql_ast/__init__.py -------------------------------------------------------------------------------- /fastgql/gql_ast/models.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | import uuid 3 | from dataclasses import dataclass, field 4 | from enum import Enum 5 | import graphql 6 | from pydantic import BaseModel 7 | from pydantic.fields import FieldInfo 8 | 9 | 10 | class OperationType(str, Enum): 11 | query = "query" 12 | mutation = "mutation" 13 | 14 | 15 | @dataclass 16 | class Argument: 17 | name: str 18 | display_name: str 19 | value: T.Any | None 20 | 21 | 22 | @dataclass(frozen=True) 23 | class Node: 24 | id: uuid.UUID 25 | original_node: graphql.Node 26 | children: list[T.Union["FieldNode", "InlineFragmentNode"]] | None 27 | 28 | def __hash__(self): 29 | return hash(self.id) 30 | 31 | 32 | @dataclass(frozen=True) 33 | class FieldNode(Node): 34 | name: str 35 | alias: str | None 36 | display_name: str 37 | arguments: list[Argument] 38 | annotation: T.Any 39 | 40 | def __hash__(self): 41 | return hash(self.id) 42 | 43 | 44 | @dataclass(frozen=True) 45 | class FieldNodeField(FieldNode): 46 | field: FieldInfo 47 | 48 | def __hash__(self): 49 | return hash(self.id) 50 | 51 | 52 | @dataclass(frozen=True) 53 | class FieldNodeMethod(FieldNode): 54 | method: T.Callable 55 | 56 | def __hash__(self): 57 | return hash(self.id) 58 | 59 | 60 | @dataclass(frozen=True) 61 | class FieldNodeModel(FieldNode): 62 | models: list[T.Type[BaseModel]] 63 | 64 | def __hash__(self): 65 | return hash(self.id) 66 | 67 | 68 | @dataclass(frozen=True) 69 | class InlineFragmentNode(Node): 70 | type_condition: str 71 | annotation: T.Any 72 | 73 | def __hash__(self): 74 | return hash(self.id) 75 | 76 | 77 | @dataclass(frozen=True) 78 | class OperationNode(Node): 79 | name: str | None 80 | type: OperationType 81 | 82 | def __hash__(self): 83 | return hash(self.id) 84 | 85 | 86 | __all__ = [ 87 | "OperationType", 88 | "Argument", 89 | "Node", 90 | "FieldNode", 91 | "FieldNodeField", 92 | "FieldNodeModel", 93 | "FieldNodeMethod", 94 | "InlineFragmentNode", 95 | "OperationNode", 96 | ] 97 | -------------------------------------------------------------------------------- /fastgql/gql_ast/translator.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | import uuid 3 | import graphql 4 | from graphql.type.definition import GraphQLNullableType 5 | from .models import ( 6 | FieldNode, 7 | FieldNodeModel, 8 | FieldNodeMethod, 9 | FieldNodeField, 10 | OperationNode, 11 | OperationType, 12 | InlineFragmentNode, 13 | Argument, 14 | ) 15 | 16 | 17 | class Translator: 18 | def __init__( 19 | self, 20 | document: graphql.DocumentNode, 21 | schema: graphql.GraphQLSchema, 22 | display_to_python_map: dict[str, str], 23 | ): 24 | self.document = document 25 | self.schema = schema 26 | self.display_to_python_map = display_to_python_map 27 | self.query_definitions: list[graphql.OperationDefinitionNode] = [] 28 | self.mutation_definitions: list[graphql.OperationDefinitionNode] = [] 29 | self.subscription_definitions: list[graphql.OperationDefinitionNode] = [] 30 | self.fragment_definitions: dict[str, graphql.FragmentDefinitionNode] = {} 31 | 32 | def parse_val(self, val: graphql.ValueNode) -> T.Any: 33 | if isinstance(val, graphql.VariableNode): 34 | return val 35 | if isinstance(val, graphql.IntValueNode): 36 | return int(val.value) 37 | if isinstance(val, graphql.FloatValueNode): 38 | return float(val.value) 39 | if isinstance(val, graphql.StringValueNode): 40 | return val.value 41 | if isinstance(val, graphql.BooleanValueNode): 42 | return bool(val.value) 43 | if isinstance(val, graphql.ObjectValueNode): 44 | return { 45 | field.name.value: self.parse_val(field.value) for field in val.fields 46 | } 47 | if isinstance(val, graphql.ListValueNode): 48 | return [self.parse_val(v) for v in val.values] 49 | if isinstance(val, graphql.NullValueNode): 50 | return None 51 | # TODO for enums, lists... might need to use pydantic validate call here...anoying w return types and all 52 | return val.value 53 | 54 | @staticmethod 55 | def combine_sels( 56 | a: graphql.InlineFragmentNode | graphql.FieldNode, 57 | b: graphql.InlineFragmentNode | graphql.FieldNode, 58 | ) -> graphql.InlineFragmentNode | graphql.FieldNode: 59 | new_node: graphql.InlineFragmentNode | graphql.FieldNode = a.__copy__() 60 | if isinstance(a, graphql.FieldNode): 61 | new_node.arguments = (*a.arguments, *b.arguments) 62 | if not new_node.selection_set: 63 | new_node.selection_set = graphql.SelectionNode() 64 | new_node.selection_set.selections = ( 65 | *(a.selection_set.selections if a.selection_set else []), 66 | *(b.selection_set.selections if b.selection_set else []), 67 | ) 68 | return new_node 69 | 70 | def children_from_node( 71 | self, 72 | gql_field: graphql.GraphQLField | graphql.GraphQLObjectType, 73 | node: graphql.FieldNode | graphql.InlineFragmentNode, 74 | path_to_children: tuple[str, ...], 75 | ) -> list[FieldNode | InlineFragmentNode] | None: 76 | if node.selection_set is None: 77 | return None 78 | children: list[FieldNode | InlineFragmentNode] = [] 79 | root_field = self.get_root_type(type_=gql_field) 80 | selection_q = list({*node.selection_set.selections}) 81 | 82 | # first, flatten and combine them 83 | has_seen: dict[str, graphql.InlineFragmentNode | graphql.FieldNode] = {} 84 | while len(selection_q) > 0: 85 | sel = selection_q.pop(0) 86 | if isinstance(sel, graphql.FragmentSpreadNode): 87 | frag = self.fragment_definitions[sel.name.value] 88 | selection_q.extend(frag.selection_set.selections) 89 | continue 90 | if isinstance(sel, graphql.FieldNode): 91 | key = sel.alias.value if sel.alias else sel.name.value 92 | elif isinstance(sel, graphql.InlineFragmentNode): 93 | key = sel.type_condition.name 94 | else: 95 | raise Exception(f"Invalid sel: {sel=}") 96 | if existing := has_seen.get(key): 97 | sel = self.combine_sels(existing, sel) 98 | has_seen[key] = sel 99 | 100 | for sel in has_seen.values(): 101 | if isinstance(sel, graphql.InlineFragmentNode): 102 | gql_field = self.get_root_type( 103 | root_field, type_condition=sel.type_condition.name.value 104 | ) 105 | else: 106 | sel_name = sel.name.value 107 | if sel_name == "__typename": 108 | gql_field = None 109 | else: 110 | gql_field = root_field.fields[sel_name] 111 | child_s = self.from_node( 112 | gql_field=gql_field, node=sel, path=path_to_children 113 | ) 114 | children.extend(child_s if isinstance(child_s, list) else [child_s]) 115 | return children 116 | 117 | # @cache #TODO cache but these models are not hashable... 118 | def get_root_type( 119 | self, 120 | type_: T.Union[ 121 | GraphQLNullableType, 122 | graphql.GraphQLNonNull, 123 | graphql.GraphQLField, 124 | graphql.GraphQLUnionType, 125 | ], 126 | type_condition: str = None, 127 | ) -> graphql.GraphQLObjectType | tuple[graphql.GraphQLUnionType] | None: 128 | if hasattr(type_, "of_type"): 129 | return self.get_root_type(type_.of_type, type_condition=type_condition) 130 | if hasattr(type_, "type"): 131 | return self.get_root_type(type_.type, type_condition=type_condition) 132 | if type_condition: 133 | if hasattr(type_, "types"): 134 | types = [self.get_root_type(t) for t in type_.types] 135 | for t in types: 136 | if t.name == type_condition: 137 | return t 138 | raise Exception("Type condition was not found.") 139 | 140 | return type_ 141 | 142 | def from_node( 143 | self, 144 | gql_field: graphql.GraphQLField | graphql.GraphQLObjectType | None, 145 | node: graphql.SelectionNode, 146 | path: tuple[str, ...], 147 | ) -> FieldNode | InlineFragmentNode | list[FieldNode | InlineFragmentNode]: 148 | """if this is a fragment, return many nodes""" 149 | if isinstance(node, graphql.FragmentSpreadNode): 150 | frag = self.fragment_definitions[node.name.value] 151 | return [ 152 | self.from_node( 153 | gql_field=gql_field, 154 | node=sel, 155 | path=path, 156 | ) 157 | for sel in frag.selection_set.selections 158 | ] 159 | 160 | if not gql_field: 161 | annotation = None 162 | elif isinstance(gql_field, graphql.GraphQLObjectType): 163 | annotation = gql_field._pydantic_model 164 | else: 165 | annotation = gql_field.type._anno 166 | 167 | if isinstance(node, graphql.InlineFragmentNode): 168 | type_condition = node.type_condition.name.value 169 | return InlineFragmentNode( 170 | id=uuid.uuid4(), 171 | children=self.children_from_node( 172 | gql_field=gql_field, 173 | node=node, 174 | path_to_children=(*path, type_condition), 175 | ), 176 | original_node=node, 177 | type_condition=type_condition, 178 | annotation=annotation, 179 | ) 180 | elif isinstance(node, graphql.FieldNode): 181 | # build args 182 | arguments: list[Argument] = [] 183 | for argument in node.arguments: 184 | arguments.append( 185 | Argument( 186 | display_name=argument.name.value, 187 | name=self.display_to_python_map[argument.name.value], 188 | value=self.parse_val(argument.value), 189 | ) 190 | ) 191 | alias = node.alias.value if node.alias else None 192 | try: 193 | name = self.display_to_python_map[node.name.value] 194 | except KeyError as e: 195 | if node.name.value == "__typename": 196 | name = node.name.value 197 | else: 198 | raise e 199 | 200 | children = self.children_from_node( 201 | gql_field=gql_field, 202 | node=node, 203 | path_to_children=(*path, node.name.value), 204 | ) 205 | display_name = node.name.value 206 | # gql_field_type = self.get_root_type(gql_field) 207 | if not gql_field: 208 | # this is __typename 209 | return FieldNode( 210 | id=uuid.uuid4(), 211 | original_node=node, 212 | children=children, 213 | name=name, 214 | alias=alias, 215 | display_name=display_name, 216 | arguments=arguments, 217 | annotation=annotation, 218 | ) 219 | gql_field_type = gql_field.type 220 | if hasattr(gql_field_type, "_field_info"): 221 | return FieldNodeField( 222 | id=uuid.uuid4(), 223 | original_node=node, 224 | children=children, 225 | name=name, 226 | alias=alias, 227 | display_name=display_name, 228 | arguments=arguments, 229 | annotation=annotation, 230 | field=gql_field_type._field_info, 231 | ) 232 | elif hasattr(gql_field_type, "_method"): 233 | return FieldNodeMethod( 234 | id=uuid.uuid4(), 235 | original_node=node, 236 | children=children, 237 | name=name, 238 | alias=alias, 239 | display_name=display_name, 240 | arguments=arguments, 241 | annotation=annotation, 242 | method=gql_field_type._method, 243 | ) 244 | else: 245 | root_type = self.get_root_type(gql_field) 246 | if isinstance(root_type, graphql.GraphQLUnionType): 247 | models = [t._pydantic_model for t in root_type.types] 248 | elif hasattr(root_type, "_pydantic_model"): 249 | models = [root_type._pydantic_model] 250 | else: 251 | # hacky fix because the enum wasn't being registered 252 | if isinstance(root_type, graphql.GraphQLEnumType): 253 | gql_field_temp = getattr(gql_field_type, "of_type") 254 | while not hasattr(gql_field_temp, "_field_info"): 255 | # TODO not sure if this is okay 256 | if not hasattr(gql_field_temp, "of_type"): 257 | return FieldNodeField( 258 | id=uuid.uuid4(), 259 | original_node=node, 260 | children=children, 261 | name=name, 262 | alias=alias, 263 | display_name=display_name, 264 | arguments=arguments, 265 | annotation=annotation, 266 | field=None, 267 | ) 268 | gql_field_temp = getattr(gql_field_temp, "of_type") 269 | return FieldNodeField( 270 | id=uuid.uuid4(), 271 | original_node=node, 272 | children=children, 273 | name=name, 274 | alias=alias, 275 | display_name=display_name, 276 | arguments=arguments, 277 | annotation=annotation, 278 | field=gql_field_temp._field_info, 279 | ) 280 | # otherwise, this will be an object type 281 | return FieldNodeModel( 282 | id=uuid.uuid4(), 283 | original_node=node, 284 | children=children, 285 | name=name, 286 | alias=alias, 287 | display_name=display_name, 288 | arguments=arguments, 289 | models=models, 290 | annotation=annotation, 291 | ) 292 | else: 293 | raise Exception(f"Invalid node type: {type(node)=}, {node=}") 294 | 295 | def from_operation_node( 296 | self, node: graphql.OperationDefinitionNode 297 | ) -> OperationNode: 298 | # first, build children, and then their children 299 | children: list[FieldNode | InlineFragmentNode] = [] 300 | # TODO possible inline frags? 301 | for sel in node.selection_set.selections: 302 | if node.operation == graphql.OperationType.MUTATION: 303 | gql_field = self.schema.mutation_type 304 | elif node.operation == graphql.OperationType.QUERY: 305 | gql_field = self.schema.query_type 306 | else: 307 | raise Exception(f"Unimplemented operation type: {node.operation=}") 308 | child_s = self.from_node( 309 | gql_field=gql_field.fields[sel.name.value], 310 | node=sel, 311 | path=(node.operation.value,), 312 | ) 313 | if type(child_s) is list: 314 | children.extend(child_s) 315 | else: 316 | children.append(child_s) 317 | 318 | op = OperationNode( 319 | id=uuid.uuid4(), 320 | name=node.name.value if node.name else None, 321 | type=OperationType(node.operation.value), 322 | children=children, 323 | original_node=node, 324 | ) 325 | 326 | return op 327 | 328 | def translate(self) -> list[OperationNode]: 329 | for definition in self.document.definitions: 330 | if isinstance(definition, graphql.FragmentDefinitionNode): 331 | self.fragment_definitions[definition.name.value] = definition 332 | elif isinstance(definition, graphql.OperationDefinitionNode): 333 | if definition.operation == graphql.OperationType.QUERY: 334 | self.query_definitions.append(definition) 335 | elif definition.operation == graphql.OperationType.MUTATION: 336 | self.mutation_definitions.append(definition) 337 | else: 338 | self.subscription_definitions.append(definition) 339 | else: 340 | raise Exception( 341 | f"Unknown type of definition: {definition=}, {type(definition)=}" 342 | ) 343 | 344 | # start with the operations 345 | op_nodes: list[OperationNode] = [] 346 | for operation in [*self.query_definitions, *self.mutation_definitions]: 347 | op_nodes.append(self.from_operation_node(operation)) 348 | return op_nodes 349 | -------------------------------------------------------------------------------- /fastgql/gql_models.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | from pydantic import BaseModel, ValidationInfo, model_validator 3 | from fastgql.gql_ast.models import Node 4 | 5 | 6 | class GQLConfigDict(T.TypedDict, total=False): 7 | """A TypedDict for configuring FastGQL behaviour.""" 8 | 9 | type_name: str 10 | input_type_name: str 11 | description: str 12 | 13 | 14 | class GQL(BaseModel): 15 | gql_config: T.ClassVar[GQLConfigDict] = {} 16 | 17 | @classmethod 18 | def gql_type_name(cls) -> str: 19 | return cls.gql_config.get("type_name", cls.__name__) 20 | 21 | @classmethod 22 | def gql_description(cls) -> str | None: 23 | return cls.gql_config.get("description") 24 | 25 | 26 | class GQLInterface(GQL): 27 | pass 28 | 29 | 30 | class GQLInput(GQL): 31 | @classmethod 32 | def gql_input_type_name(cls) -> str: 33 | return cls.gql_config.get("input_type_name", cls.__name__) 34 | 35 | @model_validator(mode="before") 36 | @classmethod 37 | def _to_snake_case(cls, data: T.Any, info: ValidationInfo) -> T.Any: 38 | if context := info.context: 39 | if display_to_python_map := context.get("_display_to_python_map"): 40 | return {display_to_python_map[k]: v for k, v in data.items()} 41 | return data 42 | 43 | 44 | class GQLError(Exception): 45 | def __init__( 46 | self, 47 | message: str, 48 | *, 49 | node: Node | list[Node] | None = None, 50 | path: tuple[str, ...] | None = None, 51 | original_error: Exception | None = None, 52 | extensions: dict[str, T.Any] | None = None, 53 | capture_exception: bool = True, 54 | ): 55 | super().__init__(message) 56 | self.message = message 57 | self.node = node 58 | self.path = path 59 | self.original_error = original_error 60 | self.extensions = extensions 61 | self.capture_exception = capture_exception 62 | 63 | 64 | __all__ = ["GQL", "GQLInput", "GQLConfigDict", "GQLError", "GQLInterface"] 65 | -------------------------------------------------------------------------------- /fastgql/info.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | import dataclasses 3 | from fastgql.gql_ast import models as M 4 | from fastgql.context import BaseContext 5 | 6 | ContextType = T.TypeVar("ContextType", bound=BaseContext) 7 | 8 | 9 | @dataclasses.dataclass 10 | class Info(T.Generic[ContextType]): 11 | """needed to make this a raw dataclass because context needs to be kept as a reference... pydantic copies dicts""" 12 | 13 | node: M.FieldNode | M.InlineFragmentNode 14 | parent_node: M.FieldNode | M.InlineFragmentNode | M.OperationNode 15 | path: tuple[str, ...] 16 | 17 | context: ContextType 18 | 19 | 20 | __all__ = ["Info", "ContextType"] 21 | -------------------------------------------------------------------------------- /fastgql/logs.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | import logging 3 | from enum import Enum 4 | 5 | FORMAT_STR = "%(asctime)s | %(process)d | %(name)s | %(levelname)s: %(message)s" 6 | 7 | 8 | class Color(str, Enum): 9 | BLACK = 0 10 | RED = 1 11 | GREEN = 2 12 | YELLOW = 3 13 | BLUE = 4 14 | PURPLE = 5 15 | TEAL = 6 16 | GREY = 7 17 | 18 | 19 | RESET_STR = "\x1b[0m" 20 | 21 | 22 | def format_str_from_color(color: Color, bold: bool = False) -> str: 23 | bold_str = "20" if not bold else "1" 24 | color_str = f"\x1b[3{color.value};{bold_str}m" 25 | format_str = f"{color_str} {FORMAT_STR} {RESET_STR}" 26 | return format_str 27 | 28 | 29 | COLORS = dict[int, Color] 30 | FORMATS = dict[int, str] 31 | 32 | DEFAULT_FORMATS: FORMATS = { 33 | logging.DEBUG: format_str_from_color(Color.TEAL), 34 | logging.INFO: format_str_from_color(Color.PURPLE), 35 | logging.WARNING: format_str_from_color(Color.YELLOW), 36 | logging.ERROR: format_str_from_color(Color.RED, bold=False), 37 | logging.CRITICAL: format_str_from_color(Color.RED, bold=True), 38 | } 39 | 40 | 41 | def formats_from_colors(color_map: COLORS) -> FORMATS: 42 | return {k: format_str_from_color(v) for k, v in color_map.items()} 43 | 44 | 45 | def formats_from_color(color: Color) -> FORMATS: 46 | format_str = format_str_from_color(color) 47 | return {k: format_str for k in DEFAULT_FORMATS.keys()} 48 | 49 | 50 | class CustomFormatter(logging.Formatter): 51 | def __init__( 52 | self, 53 | formats: FORMATS | None = None, 54 | no_colors: bool = False, 55 | *args: T.Any, 56 | **kwargs: T.Any, 57 | ): 58 | super().__init__(*args, **kwargs) 59 | self.formats = formats or DEFAULT_FORMATS 60 | self.no_colors = no_colors 61 | 62 | def format(self, record: logging.LogRecord) -> str: 63 | if self.no_colors: 64 | format_str = FORMAT_STR 65 | else: 66 | format_str = self.formats[record.levelno] 67 | formatter = logging.Formatter(format_str) 68 | return formatter.format(record) 69 | 70 | 71 | def create_logger( 72 | name: str, 73 | level: T.Optional[T.Any] = logging.DEBUG, 74 | colors_map: T.Optional[COLORS] = None, 75 | no_colors: T.Optional[bool] = False, 76 | one_color: T.Optional[Color] = None, 77 | ) -> logging.Logger: 78 | logger = logging.getLogger(name) 79 | logger.setLevel(level=level) 80 | handler = logging.StreamHandler() 81 | if one_color: 82 | formats = formats_from_color(one_color) 83 | elif colors_map: 84 | formats = formats_from_colors(colors_map) 85 | else: 86 | formats = None 87 | handler.setFormatter(CustomFormatter(formats=formats, no_colors=no_colors)) 88 | logger.addHandler(handler) 89 | return logger 90 | 91 | 92 | __all__ = ["FORMATS", "Color", "create_logger"] 93 | -------------------------------------------------------------------------------- /fastgql/query_builders/edgedb/config.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | import inspect 3 | from dataclasses import dataclass 4 | 5 | from fastgql.info import Info 6 | from fastgql.gql_ast import models as M 7 | from fastgql.execute.utils import parse_value 8 | from fastgql.utils import node_from_path 9 | from .query_builder import QueryBuilder, ChildEdge 10 | 11 | 12 | def combine_qbs( 13 | *qbs: QueryBuilder, 14 | nodes_to_include: list[ 15 | M.FieldNode 16 | ], # TODO so far, not needed but may for better inheritence 17 | ) -> QueryBuilder: 18 | """takes in a dict of db typename and qb""" 19 | new_qb = QueryBuilder() 20 | # add dangling to each qb 21 | for node in nodes_to_include: 22 | for qb in qbs: 23 | if node.name == "__typename": 24 | qb.fields.add(f"{node.alias or node.name} := .__type__.name") 25 | # TODO other things here 26 | for qb in qbs: 27 | if not qb.typename: 28 | raise Exception("QB must have a typename if it is to be combined.") 29 | new_qb.children[qb.typename] = ChildEdge( 30 | db_expression=f"[is {qb.typename}]", qb=qb 31 | ) 32 | return new_qb 33 | 34 | 35 | @dataclass 36 | class Property: 37 | db_name: str | None 38 | update_qb: T.Callable[[QueryBuilder], T.Awaitable[None] | None] = None 39 | 40 | 41 | @dataclass 42 | class Link: 43 | db_name: str | None 44 | return_cls_qb_config: ( 45 | T.Union["QueryBuilderConfig", dict[str, "QueryBuilderConfig"]] | None 46 | ) = None 47 | path_to_return_cls: tuple[str, ...] | None = None 48 | update_qbs: T.Callable[[..., T.Any], None | T.Awaitable] = None 49 | 50 | 51 | @dataclass 52 | class QueryBuilderConfig: 53 | properties: dict[str, Property] 54 | links: dict[str, Link] 55 | 56 | def is_empty(self) -> bool: 57 | return not self.properties and not self.links 58 | 59 | async def from_info( 60 | self, info: Info, node: M.FieldNode | M.InlineFragmentNode 61 | ) -> QueryBuilder | None: 62 | if not node: 63 | return None 64 | qb = QueryBuilder() 65 | children_q = [*node.children] 66 | while len(children_q) > 0: 67 | child = children_q.pop(0) 68 | if isinstance(child, M.InlineFragmentNode): 69 | children_q.extend(child.children) 70 | else: 71 | if child.name in self.properties: 72 | property_config = self.properties[child.name] 73 | if db_name := property_config.db_name: 74 | if child.name != db_name: 75 | qb.fields.add(f"{child.name} := .{db_name}") 76 | else: 77 | qb.fields.add(db_name) 78 | if update_qb := property_config.update_qb: 79 | kwargs = { 80 | "qb": qb, 81 | "node": node, 82 | "child_node": child, 83 | "info": info, 84 | **{ 85 | a.name: parse_value( 86 | variables=info.context.variables, v=a.value 87 | ) 88 | for a in child.arguments 89 | }, 90 | } 91 | kwargs = { 92 | k: v 93 | for k, v in kwargs.items() 94 | if k in inspect.signature(update_qb).parameters 95 | } 96 | _ = update_qb(**kwargs) 97 | if inspect.isawaitable(_): 98 | await _ 99 | if child.name in self.links: 100 | method_config = self.links[child.name] 101 | original_child = child 102 | if method_config.path_to_return_cls: 103 | child = node_from_path( 104 | node=child, path=[*method_config.path_to_return_cls] 105 | ) 106 | if config := method_config.return_cls_qb_config: 107 | if isinstance(config, dict): 108 | dangling_children: list[M.FieldNode] = [] 109 | frag_qbs: list[QueryBuilder] = [] 110 | for child_child in child.children: 111 | if isinstance(child_child, M.InlineFragmentNode): 112 | child_child_qb = await config[ 113 | child_child.type_condition 114 | ].from_info(info=info, node=child_child) 115 | child_child_qb.typename = child_child.type_condition 116 | frag_qbs.append(child_child_qb) 117 | elif isinstance(child_child, M.FieldNode): 118 | dangling_children.append(child_child) 119 | else: 120 | raise Exception( 121 | f"Invalid node for config as dict: {child=}" 122 | ) 123 | # now combine the dangling with the frags 124 | child_qb = combine_qbs( 125 | *frag_qbs, nodes_to_include=dangling_children 126 | ) 127 | child_qb.fields.add("typename := .__type__.name") 128 | else: 129 | child_qb = await config.from_info(info=info, node=child) 130 | if db_name := method_config.db_name: 131 | name_to_use = child.alias or child.name 132 | db_expression = ( 133 | None if name_to_use == db_name else f".{db_name}" 134 | ) 135 | qb.children[name_to_use] = ChildEdge( 136 | db_expression=db_expression, qb=child_qb 137 | ) 138 | if update_qbs := method_config.update_qbs: 139 | kwargs = { 140 | "qb": qb, 141 | "child_qb": child_qb, 142 | "node": node, 143 | "child_node": child, 144 | "info": info, 145 | **{ 146 | a.name: parse_value( 147 | variables=info.context.variables, v=a.value 148 | ) 149 | for a in original_child.arguments 150 | }, 151 | } 152 | kwargs = { 153 | k: v 154 | for k, v in kwargs.items() 155 | if k in inspect.signature(update_qbs).parameters 156 | } 157 | _ = update_qbs(**kwargs) 158 | if inspect.isawaitable(_): 159 | await _ 160 | return qb 161 | -------------------------------------------------------------------------------- /fastgql/query_builders/edgedb/logic.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | import types 3 | import time 4 | import inspect 5 | import graphql 6 | from pydantic import BaseModel 7 | from devtools import debug 8 | 9 | from fastgql.execute.utils import get_root_type 10 | from fastgql.info import Info 11 | from .config import QueryBuilderConfig, Link, Property 12 | from .query_builder import QueryBuilder 13 | 14 | 15 | def get_qb_config_from_gql_field( 16 | gql_field: graphql.GraphQLField, path_to_return_cls: tuple[str, ...] = None 17 | ) -> QueryBuilderConfig: 18 | root = get_root_type(gql_field) 19 | if path_to_return_cls: 20 | path = [*path_to_return_cls] 21 | while path: 22 | p = path.pop(0) 23 | root = get_root_type(root.fields[p]) 24 | if isinstance(root, list): 25 | child_qb_config = {cm.name: cm._pydantic_model.qb_config for cm in root} 26 | else: 27 | child_qb_config = root._pydantic_model.qb_config 28 | return child_qb_config 29 | 30 | 31 | def build_from_schema(schema: graphql.GraphQLSchema) -> None: 32 | start = time.time() 33 | print("starting to build gql") 34 | gql_models = [ 35 | m 36 | for m in schema.type_map.values() 37 | if isinstance(m, graphql.GraphQLObjectType) and hasattr(m, "_pydantic_model") 38 | ] 39 | for gql_model in gql_models: 40 | gql_model._pydantic_model.qb_config = QueryBuilderConfig( 41 | properties={}, links={} 42 | ) 43 | for gql_model in gql_models: 44 | pydantic_model = gql_model._pydantic_model 45 | config: QueryBuilderConfig = pydantic_model.qb_config 46 | # first do fields 47 | for field_name, field_info in pydantic_model.model_fields.items(): 48 | meta_list = field_info.metadata 49 | for meta in meta_list: 50 | if isinstance(meta, Property): 51 | config.properties[field_name] = meta 52 | elif isinstance(meta, Link): 53 | # but then need to populated nested 54 | if not meta.return_cls_qb_config: 55 | meta.return_cls_qb_config = get_qb_config_from_gql_field( 56 | gql_model.fields[field_name] 57 | ) 58 | config.links[field_name] = meta 59 | # now do functions 60 | for name, member in inspect.getmembers(pydantic_model): 61 | if inspect.isfunction(member): 62 | return_annotation = inspect.signature(member).return_annotation 63 | if isinstance(return_annotation, (T.Annotated, T._AnnotatedAlias)): 64 | for meta in return_annotation.__metadata__: 65 | if isinstance(meta, Link): 66 | if not meta.return_cls_qb_config: 67 | fields_by_og_name = { 68 | f._og_name: f for f in gql_model.fields.values() 69 | } 70 | meta.return_cls_qb_config = ( 71 | get_qb_config_from_gql_field( 72 | fields_by_og_name[name], 73 | path_to_return_cls=meta.path_to_return_cls, 74 | ) 75 | ) 76 | config.links[name] = meta 77 | elif isinstance(meta, Property): 78 | config.properties[name] = meta 79 | 80 | # for gql_model in gql_models: 81 | # if gql_model.name == "EventUserPublic": 82 | # print(gql_model.name) 83 | # debug(gql_model._pydantic_model.qb_config) 84 | print( 85 | f"[EDGEDB QB CONFIG BUILDING] building the qb configs took: {(time.time() - start) * 1000} ms" 86 | ) 87 | 88 | 89 | def root_type_s_from_annotation( 90 | a: T.Any, 91 | ) -> T.Type[BaseModel] | list[T.Type[BaseModel]]: 92 | if inspect.isclass(a): 93 | return a 94 | else: 95 | origin = T.get_origin(a) 96 | args = T.get_args(a) 97 | if origin is list or origin is types.UnionType or origin is T.Union: 98 | non_none_args = [] 99 | for arg in args: 100 | if not ( 101 | arg is None 102 | or (inspect.isclass(arg) and issubclass(arg, type(None))) 103 | ): 104 | non_none_args.append(arg) 105 | if len(non_none_args) == 1: # that means non was taken out? 106 | return root_type_s_from_annotation(non_none_args[0]) 107 | else: 108 | return [root_type_s_from_annotation(arg) for arg in args] 109 | 110 | 111 | async def get_qb(info: Info) -> QueryBuilder: 112 | annotation = info.node.annotation 113 | root_type_s = root_type_s_from_annotation(annotation) 114 | if type(root_type_s) is list: 115 | existing_config = None 116 | for root_type in root_type_s: 117 | qb_config: QueryBuilderConfig = getattr(root_type, "qb_config", None) 118 | if qb_config and not qb_config.is_empty(): 119 | if existing_config: 120 | debug(qb_config, existing_config) 121 | raise Exception("You cannot have conflicting qb_configs.") 122 | existing_config = qb_config 123 | if not existing_config: 124 | raise Exception("There is no return model with a qb_config.") 125 | return await existing_config.from_info(info=info, node=info.node) 126 | else: 127 | return await root_type_s.qb_config.from_info(info=info, node=info.node) 128 | -------------------------------------------------------------------------------- /fastgql/query_builders/edgedb/models.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | from pydantic import BaseModel 3 | from fastgql.query_builders.edgedb.config import QueryBuilderConfig 4 | 5 | 6 | class QB(BaseModel): 7 | qb_config: T.ClassVar[QueryBuilderConfig] 8 | -------------------------------------------------------------------------------- /fastgql/query_builders/edgedb/query_builder.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | from enum import Enum 3 | import random 4 | import string 5 | import re 6 | from pydantic import BaseModel, Field 7 | 8 | 9 | class FilterConnector(str, Enum): 10 | AND = "AND" 11 | OR = "OR" 12 | 13 | 14 | class QueryBuilderError(Exception): 15 | pass 16 | 17 | 18 | class ChildEdge(BaseModel): 19 | # for example, .artists 20 | db_expression: str | None 21 | qb: "QueryBuilder" 22 | 23 | 24 | class QueryBuilder(BaseModel): 25 | typename: str | None = None 26 | fields: set[str] = Field(default_factory=set) 27 | variables: dict[str, T.Any] = Field(default_factory=dict) 28 | children: dict[str, ChildEdge] = Field(default_factory=dict) 29 | filter: str | None = None 30 | order_by: str | None = None 31 | offset: str | None = None 32 | limit: str | None = None 33 | 34 | full_query_str: str | None = None 35 | pattern_to_replace: str | None = None 36 | 37 | @staticmethod 38 | def build_child_var_name( 39 | child_name: str, var_name: str, variables_to_use: dict[str, T.Any] 40 | ) -> str: 41 | child_name = re.sub(r"[^a-zA-Z0-9]+", "_", child_name) 42 | count = 0 43 | while var_name in variables_to_use: 44 | count_str = "" if not count else f"_{count}" 45 | var_name = f"_{child_name}{count_str}_{var_name}" 46 | return var_name 47 | 48 | def build(self) -> tuple[str, dict[str, T.Any]]: 49 | variables_to_use = self.variables.copy() 50 | child_strs: set[str] = set() 51 | for child_name, child_edge in self.children.items(): 52 | child = child_edge.qb 53 | child_str, child_variables = child.build() 54 | for var_name, var_val in child_variables.items(): 55 | if var_name in variables_to_use: 56 | if var_val is variables_to_use[var_name]: 57 | continue 58 | # must change the name for the child 59 | new_var_name = self.build_child_var_name( 60 | child_name=child_name, 61 | var_name=var_name, 62 | variables_to_use=variables_to_use, 63 | ) 64 | variables_to_use[new_var_name] = var_val 65 | # now, must regex the str to find this and replace it 66 | regex = re.compile(r"\${}(?!\w)".format(var_name)) 67 | child_str = regex.sub(f"${new_var_name}", child_str) 68 | else: 69 | variables_to_use[var_name] = var_val 70 | if not child_edge.db_expression: 71 | child_str = f"{child_name}: {child_str}" 72 | else: 73 | child_str = ( 74 | f"{child_name} := (select {child_edge.db_expression} {child_str})" 75 | ) 76 | child_strs.add(child_str) 77 | 78 | fields_str = ", ".join([*sorted(self.fields), *sorted(child_strs)]) 79 | s_parts = ["" if not fields_str else f"{{ {fields_str} }}"] 80 | if self.filter: 81 | s_parts.append(f"FILTER {self.filter}") 82 | if self.order_by: 83 | s_parts.append(f"ORDER BY {self.order_by}") 84 | if self.offset is not None: 85 | s_parts.append(self.offset) 86 | if self.limit is not None: 87 | s_parts.append(self.limit) 88 | final_s = " ".join(s_parts) 89 | if self.full_query_str: 90 | final_s = self.full_query_str.replace(self.pattern_to_replace, final_s) 91 | return final_s, variables_to_use 92 | 93 | def add_variable( 94 | self, key: str, val: T.Any, replace: bool = False 95 | ) -> "QueryBuilder": 96 | if key in self.variables: 97 | if not replace and self.variables[key] != val: 98 | raise QueryBuilderError( 99 | f"Key {key} already exists in variables so you cannot add it. " 100 | f"If you'd like to replace it, pass replace." 101 | ) 102 | self.variables[key] = val 103 | return self 104 | 105 | def add_variables( 106 | self, variables: dict[str, T.Any], replace: bool = False 107 | ) -> "QueryBuilder": 108 | """if there is an error, it does not save to the builder""" 109 | if not variables: 110 | return self 111 | if not replace: 112 | for k in variables.keys(): 113 | if k in self.variables: 114 | raise QueryBuilderError( 115 | f"Key {k} already exists in variables so you cannot add it. " 116 | f"If you'd like to replace it, pass replace." 117 | ) 118 | self.variables.update(variables) 119 | return self 120 | 121 | # ADD HELPER FUNCTIONS FOR LIMIT AND OFFSET -> like add_offset... 122 | def set_offset(self, offset: int | None, replace: bool = False) -> "QueryBuilder": 123 | if offset is None: 124 | return self 125 | if self.offset is not None: 126 | if not replace: 127 | raise QueryBuilderError( 128 | "An offset already exists. If you would like to replace it, pass in replace." 129 | ) 130 | self.offset = "OFFSET $offset" 131 | self.add_variable("offset", offset, replace=replace) 132 | return self 133 | 134 | def set_limit(self, limit: int | None, replace: bool = False) -> "QueryBuilder": 135 | if limit is None: 136 | return self 137 | if self.limit is not None: 138 | if not replace: 139 | raise QueryBuilderError( 140 | "A limit already exists. If you would like to replace it, pass in replace." 141 | ) 142 | self.limit = "LIMIT $limit" 143 | self.add_variable("limit", limit, replace=replace) 144 | return self 145 | 146 | # needs to be add_filter and add_order_by 147 | 148 | def set_filter( 149 | self, 150 | filter: str, 151 | variables: dict[str, T.Any] | None = None, 152 | replace_filter: bool = False, 153 | replace_variables: bool = False, 154 | ) -> "QueryBuilder": 155 | if self.filter and not replace_filter: 156 | raise QueryBuilderError("Filter already exists.") 157 | self.add_variables(variables=variables, replace=replace_variables) 158 | self.filter = filter 159 | return self 160 | 161 | def set_order_by( 162 | self, 163 | order_by: str, 164 | variables: dict[str, T.Any] | None = None, 165 | replace_order_by: bool = False, 166 | replace_variables: bool = False, 167 | ) -> "QueryBuilder": 168 | if self.order_by and not replace_order_by: 169 | raise QueryBuilderError("Order by already exists.") 170 | self.add_variables(variables=variables, replace=replace_variables) 171 | self.order_by = order_by 172 | return self 173 | 174 | def set_full_query_str( 175 | self, 176 | full_query_str: str, 177 | replace: bool = False, 178 | variables: dict[str, T.Any] | None = None, 179 | replace_variables: bool = False, 180 | ) -> "QueryBuilder": 181 | if self.full_query_str and not replace: 182 | raise QueryBuilderError("full_query_str already exists.") 183 | self.add_variables(variables=variables, replace=replace_variables) 184 | pattern_to_replace = "".join( 185 | random.choice(string.ascii_letters + string.digits) for _ in range(10) 186 | ) 187 | self.full_query_str = full_query_str.replace("$$", pattern_to_replace) 188 | self.pattern_to_replace = pattern_to_replace 189 | return self 190 | -------------------------------------------------------------------------------- /fastgql/query_builders/sql/config.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | import inspect 3 | from dataclasses import dataclass 4 | from pydantic import TypeAdapter 5 | 6 | from fastgql.info import Info 7 | from fastgql.gql_ast import models as M 8 | from fastgql.utils import node_from_path 9 | from .query_builder import QueryBuilder, Cardinality 10 | 11 | from fastgql.execute.executor import DISPLAY_TO_PYTHON_MAP 12 | 13 | 14 | @dataclass 15 | class Property: 16 | path: str | None 17 | update_qb: T.Callable[[..., T.Any], None | T.Awaitable] = None 18 | 19 | 20 | @dataclass 21 | class Link: 22 | # table_name: str | None 23 | from_: str | None 24 | cardinality: Cardinality 25 | 26 | return_cls_qb_config: ( 27 | T.Union["QueryBuilderConfig", dict[str, "QueryBuilderConfig"]] | None 28 | ) = None 29 | path_to_return_cls: tuple[str, ...] | None = None 30 | update_qbs: T.Callable[[..., T.Any], None | T.Awaitable] = None 31 | 32 | # these are for unions 33 | from_mapping: dict[str, str] | None = None 34 | update_qbs_mapping: dict[str, T.Callable[[..., T.Any], None | T.Awaitable]] = None 35 | 36 | 37 | async def execute_update_qb( 38 | update_qb: T.Callable[[..., T.Any], None | T.Awaitable], 39 | qb: QueryBuilder, 40 | node: M.FieldNode, 41 | child: M.FieldNode, 42 | info: Info, 43 | ) -> None: 44 | new_kwargs: dict[str, T.Any] = {} 45 | sig = inspect.signature(update_qb) 46 | args_by_name: dict[str, M.Argument] = {a.name: a for a in child.arguments} 47 | for name, param in sig.parameters.items(): 48 | if name in args_by_name: 49 | arg = args_by_name[name] 50 | val = arg.value 51 | if val is not None: 52 | val = TypeAdapter(param.annotation).validate_python( 53 | val, context={"_display_to_python_map": DISPLAY_TO_PYTHON_MAP} 54 | ) 55 | new_kwargs[name] = val 56 | elif name == "qb": 57 | new_kwargs[name] = qb 58 | elif name == "node": 59 | new_kwargs[name] = node 60 | elif name == "child_node": 61 | new_kwargs[name] = child 62 | elif name == "info": 63 | new_kwargs[name] = info 64 | 65 | _ = update_qb(**new_kwargs) 66 | if inspect.isawaitable(_): 67 | await _ 68 | 69 | 70 | async def execute_update_qbs( 71 | update_qbs: T.Callable[[..., T.Any], None | T.Awaitable], 72 | original_child: M.FieldNode, 73 | qb: QueryBuilder, 74 | child_qb: QueryBuilder, 75 | node: M.FieldNode, 76 | child: M.FieldNode | M.InlineFragmentNode, 77 | info: Info, 78 | ) -> None: 79 | new_kwargs: dict[str, T.Any] = {} 80 | sig = inspect.signature(update_qbs) 81 | args_by_name: dict[str, M.Argument] = {a.name: a for a in original_child.arguments} 82 | for name, param in sig.parameters.items(): 83 | if name in args_by_name: 84 | arg = args_by_name[name] 85 | val = arg.value 86 | if val is not None: 87 | val = TypeAdapter(param.annotation).validate_python( 88 | val, context={"_display_to_python_map": DISPLAY_TO_PYTHON_MAP} 89 | ) 90 | new_kwargs[name] = val 91 | elif name == "qb": 92 | new_kwargs[name] = qb 93 | elif name == "child_qb": 94 | new_kwargs[name] = child_qb 95 | elif name == "node": 96 | new_kwargs[name] = node 97 | elif name == "child_node": 98 | new_kwargs[name] = child 99 | elif name == "info": 100 | new_kwargs[name] = info 101 | elif name == "original_child": 102 | new_kwargs[name] = original_child 103 | 104 | _ = update_qbs(**new_kwargs) 105 | if inspect.isawaitable(_): 106 | await _ 107 | 108 | 109 | @dataclass 110 | class QueryBuilderConfig: 111 | table_name: str 112 | 113 | properties: dict[str, Property] 114 | links: dict[str, Link] 115 | 116 | def is_empty(self) -> bool: 117 | return not self.properties and not self.links 118 | 119 | async def from_info( 120 | self, 121 | info: Info, 122 | node: M.FieldNode | M.InlineFragmentNode, 123 | cardinality: Cardinality, 124 | ) -> QueryBuilder | None: 125 | if not node: 126 | return None 127 | qb = QueryBuilder(table_name=self.table_name, cardinality=cardinality) 128 | children_q = [*node.children] 129 | while len(children_q) > 0: 130 | child = children_q.pop(0) 131 | if isinstance(child, M.InlineFragmentNode): 132 | children_q.extend(child.children) 133 | else: 134 | if child.name in self.properties: 135 | property_config = self.properties[child.name] 136 | 137 | if path_to_value := property_config.path: 138 | qb.sel(name=child.name, path=path_to_value) 139 | if update_qb := property_config.update_qb: 140 | await execute_update_qb( 141 | update_qb=update_qb, 142 | qb=qb, 143 | node=node, 144 | child=child, 145 | info=info, 146 | ) 147 | if child.name in self.links: 148 | method_config = self.links[child.name] 149 | if method_config.from_mapping and method_config.from_: 150 | raise Exception("Cannot provide both from_mapping and from_.") 151 | original_child = child 152 | if method_config.path_to_return_cls: 153 | child = node_from_path( 154 | node=child, path=[*method_config.path_to_return_cls] 155 | ) 156 | if config := method_config.return_cls_qb_config: 157 | if isinstance(config, dict): 158 | # first, get the dangling children, so we can add them to the fragments 159 | dangling_children: list[M.FieldNode] = [] 160 | _type_condition_children: list[M.InlineFragmentNode] = [] 161 | for c in child.children: 162 | if isinstance(c, M.FieldNode): 163 | dangling_children.append(c) 164 | elif isinstance(c, M.InlineFragmentNode): 165 | _type_condition_children.append(c) 166 | else: 167 | raise Exception( 168 | f"Invalid node for config as dict: {c=}, {child=}" 169 | ) 170 | 171 | # must combine type condition children for repeats 172 | node_by_tc: dict[str, M.InlineFragmentNode] = {} 173 | for tc_node in _type_condition_children: 174 | type_condition = tc_node.type_condition 175 | if seen_node := node_by_tc.get(type_condition): 176 | seen_node.children.extend(tc_node.children) 177 | else: 178 | node_by_tc[type_condition] = tc_node 179 | 180 | for child_child in node_by_tc.values(): 181 | # now add dangling children to these children 182 | child_child.children.extend(dangling_children) 183 | child_child_qb: QueryBuilder = await config[ 184 | child_child.type_condition 185 | ].from_info( 186 | info=info, 187 | node=child_child, 188 | cardinality=method_config.cardinality, 189 | ) 190 | from_where = None 191 | if method_config.from_mapping: 192 | from_where = method_config.from_mapping.get( 193 | child_child.type_condition 194 | ) 195 | if not from_where: 196 | from_where = method_config.from_ 197 | if from_where: 198 | qb.sel_sub( 199 | name=f"{child.alias or child.name}__{child_child.type_condition}", 200 | qb=child_child_qb.set_from(from_where), 201 | ) 202 | if update_qbs := method_config.update_qbs: 203 | await execute_update_qbs( 204 | update_qbs=update_qbs, 205 | original_child=original_child, 206 | qb=qb, 207 | child_qb=child_child_qb, 208 | node=node, # maybe this should be child 209 | child=child_child, 210 | info=info, 211 | ) 212 | if method_config.update_qbs_mapping: 213 | if ( 214 | update_qbs_condition 215 | := method_config.update_qbs_mapping.get( 216 | child_child.type_condition 217 | ) 218 | ): 219 | await execute_update_qbs( 220 | update_qbs=update_qbs_condition, 221 | original_child=original_child, 222 | qb=qb, 223 | child_qb=child_child_qb, 224 | node=node, # maybe this should be child 225 | child=child_child, 226 | info=info, 227 | ) 228 | 229 | else: 230 | child_qb = await config.from_info( 231 | info=info, 232 | node=child, 233 | cardinality=method_config.cardinality, 234 | ) 235 | if from_where := method_config.from_: 236 | qb.sel_sub( 237 | name=child.alias or child.name, 238 | qb=child_qb.set_from(from_where), 239 | ) 240 | if update_qbs := method_config.update_qbs: 241 | await execute_update_qbs( 242 | update_qbs=update_qbs, 243 | original_child=original_child, 244 | qb=qb, 245 | child_qb=child_qb, 246 | node=node, 247 | child=child, 248 | info=info, 249 | ) 250 | return qb 251 | -------------------------------------------------------------------------------- /fastgql/query_builders/sql/logic.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | import types 3 | import time 4 | import inspect 5 | import graphql 6 | from pydantic import BaseModel 7 | from devtools import debug 8 | 9 | from fastgql.execute.utils import get_root_type 10 | from fastgql.info import Info 11 | from .config import QueryBuilderConfig, Link, Property, Cardinality 12 | from .query_builder import QueryBuilder 13 | 14 | 15 | def get_qb_config_from_gql_field( 16 | gql_field: graphql.GraphQLField, path_to_return_cls: tuple[str, ...] = None 17 | ) -> QueryBuilderConfig: 18 | root = get_root_type(gql_field) 19 | if path_to_return_cls: 20 | path = [*path_to_return_cls] 21 | while path: 22 | p = path.pop(0) 23 | root = get_root_type(root.fields[p]) 24 | if isinstance(root, list): 25 | child_qb_config = {cm.name: cm._pydantic_model.qb_config_sql for cm in root} 26 | else: 27 | child_qb_config = root._pydantic_model.qb_config_sql 28 | return child_qb_config 29 | 30 | 31 | def build_from_schema(schema: graphql.GraphQLSchema) -> None: 32 | start = time.time() 33 | print("starting to build gql") 34 | gql_models = [ 35 | m 36 | for m in schema.type_map.values() 37 | if isinstance(m, graphql.GraphQLObjectType) and hasattr(m, "_pydantic_model") 38 | ] 39 | for gql_model in gql_models: 40 | table_name = getattr( 41 | gql_model._pydantic_model, 42 | "sql_table_name", 43 | f'"{gql_model._pydantic_model.__name__}"', 44 | ) 45 | gql_model._pydantic_model.qb_config_sql = QueryBuilderConfig( 46 | properties={}, 47 | links={}, 48 | table_name=table_name, 49 | ) 50 | for gql_model in gql_models: 51 | pydantic_model = gql_model._pydantic_model 52 | config: QueryBuilderConfig = pydantic_model.qb_config_sql 53 | # first do fields 54 | for field_name, field_info in pydantic_model.model_fields.items(): 55 | meta_list = field_info.metadata 56 | for meta in meta_list: 57 | if isinstance(meta, Property): 58 | config.properties[field_name] = meta 59 | elif isinstance(meta, Link): 60 | # but then need to populated nested 61 | if not meta.return_cls_qb_config: 62 | meta.return_cls_qb_config = get_qb_config_from_gql_field( 63 | gql_model.fields[field_name] 64 | ) 65 | config.links[field_name] = meta 66 | # now do functions 67 | for name, member in inspect.getmembers(pydantic_model): 68 | if inspect.isfunction(member): 69 | return_annotation = inspect.signature(member).return_annotation 70 | if isinstance(return_annotation, (T.Annotated, T._AnnotatedAlias)): 71 | for meta in return_annotation.__metadata__: 72 | if isinstance(meta, Link): 73 | if not meta.return_cls_qb_config: 74 | fields_by_og_name = { 75 | f._og_name: f for f in gql_model.fields.values() 76 | } 77 | meta.return_cls_qb_config = ( 78 | get_qb_config_from_gql_field( 79 | fields_by_og_name[name], 80 | path_to_return_cls=meta.path_to_return_cls, 81 | ) 82 | ) 83 | config.links[name] = meta 84 | elif isinstance(meta, Property): 85 | config.properties[name] = meta 86 | 87 | # for gql_model in gql_models: 88 | # debug(gql_model._pydantic_model.qb_config_sql) 89 | print( 90 | f"[SQL QB CONFIG BUILDING] building the qb configs took: {(time.time() - start) * 1000} ms" 91 | ) 92 | 93 | 94 | def root_type_s_from_annotation( 95 | a: T.Any, 96 | ) -> T.Type[BaseModel] | list[T.Type[BaseModel]]: 97 | if inspect.isclass(a): 98 | return a 99 | else: 100 | origin = T.get_origin(a) 101 | args = T.get_args(a) 102 | if origin is list or origin is types.UnionType or origin is T.Union: 103 | non_none_args = [] 104 | for arg in args: 105 | if not ( 106 | arg is None 107 | or (inspect.isclass(arg) and issubclass(arg, type(None))) 108 | ): 109 | non_none_args.append(arg) 110 | if len(non_none_args) == 1: # that means non was taken out? 111 | return root_type_s_from_annotation(non_none_args[0]) 112 | else: 113 | return [root_type_s_from_annotation(arg) for arg in args] 114 | 115 | 116 | async def get_qb(info: Info) -> QueryBuilder: 117 | annotation = info.node.annotation 118 | root_type_s = root_type_s_from_annotation(annotation) 119 | cardinality = ( 120 | Cardinality.MANY if "list[" in str(annotation).lower() else Cardinality.ONE 121 | ) 122 | if type(root_type_s) is list: 123 | existing_config = None 124 | for root_type in root_type_s: 125 | qb_config: QueryBuilderConfig = getattr(root_type, "qb_config_sql", None) 126 | if qb_config and not qb_config.is_empty(): 127 | if existing_config: 128 | debug(qb_config, existing_config) 129 | raise Exception("You cannot have conflicting qb_configs.") 130 | existing_config = qb_config 131 | if not existing_config: 132 | raise Exception("There is no return model with a qb_config.") 133 | return await existing_config.from_info( 134 | info=info, node=info.node, cardinality=cardinality 135 | ) 136 | else: 137 | return await root_type_s.qb_config_sql.from_info( 138 | info=info, node=info.node, cardinality=cardinality 139 | ) 140 | -------------------------------------------------------------------------------- /fastgql/query_builders/sql/query_builder.py: -------------------------------------------------------------------------------- 1 | import random 2 | import re 3 | import string 4 | import typing as T 5 | from enum import Enum 6 | 7 | import sqlparse 8 | from pydantic import BaseModel, Field 9 | 10 | 11 | class PostgresDriver(str, Enum): 12 | SQLALCHEMY = "SQLALCHEMY" 13 | PSYCOPG3 = "PSYCOPG3" 14 | ASYNCPG = "ASYNCPG" 15 | 16 | 17 | ListT = T.TypeVar("ListT") 18 | 19 | 20 | def random_string(length: int) -> str: 21 | return "".join(random.choice(string.ascii_letters) for _ in range(length)) 22 | 23 | 24 | def chunk_list(lst: list[ListT], chunk_size: int) -> list[list[ListT]]: 25 | return [lst[i : i + chunk_size] for i in range(0, len(lst), chunk_size)] 26 | 27 | 28 | def split_text_around_where(text: str) -> tuple[str, str | None]: 29 | # Regex pattern to split the string around 'where' (case-insensitive) 30 | pattern = r"(?i)\bwhere\b" 31 | 32 | # Splitting the string 33 | parts = re.split(pattern, text, maxsplit=1) 34 | 35 | # Stripping whitespace from both parts 36 | before_where = parts[0].strip() 37 | after_where = parts[1].strip() if len(parts) > 1 else None 38 | 39 | return before_where, after_where 40 | 41 | 42 | class FilterConnector(str, Enum): 43 | AND = "AND" 44 | OR = "OR" 45 | 46 | 47 | class QueryBuilderError(Exception): 48 | pass 49 | 50 | 51 | class Cardinality(str, Enum): 52 | ONE = "ONE" 53 | MANY = "MANY" 54 | 55 | 56 | class Selection(BaseModel): 57 | name: str 58 | 59 | @property 60 | def is_simple_column(self) -> bool: 61 | if not isinstance(self, SelectionField): 62 | return False 63 | return ( 64 | self.name == self.path 65 | or self.path == f"$current.{self.name}" 66 | or self.path == f'"{self.path}"' 67 | or self.path == f'$current."{self.name}"' 68 | ) 69 | 70 | 71 | class SelectionField(Selection): 72 | path: str 73 | variables: dict[str, T.Any] | None = None 74 | 75 | 76 | class SelectionSub(Selection): 77 | qb: "QueryBuilder" 78 | 79 | 80 | class CTE(BaseModel): 81 | cte_str: str 82 | join_str: str 83 | is_top_level: bool = True 84 | 85 | 86 | class QueryBuilder(BaseModel): 87 | table_name: str 88 | table_alias: str | None = None 89 | 90 | cardinality: Cardinality 91 | selections: list[SelectionField | SelectionSub] = Field(default_factory=list) 92 | variables: dict[str, T.Any] = Field(default_factory=dict) 93 | 94 | ctes: list[CTE] = Field(default_factory=list) 95 | 96 | from_: str | None = None 97 | where: str | None = None 98 | 99 | order_by: str | None = None 100 | offset: str | None = None 101 | limit: str | None = None 102 | 103 | full_query_str: str | None = None 104 | pattern_to_replace: str | None = None 105 | 106 | is_count: bool = False 107 | 108 | @staticmethod 109 | def build_child_var_name( 110 | child_name: str, var_name: str, variables_to_use: dict[str, T.Any] 111 | ) -> str: 112 | child_name = re.sub(r"[^a-zA-Z0-9]+", "_", child_name) 113 | count = 0 114 | while var_name in variables_to_use: 115 | count_str = "" if not count else f"_{count}" 116 | var_name = f"_{child_name}{count_str}_{var_name}" 117 | return var_name 118 | 119 | def add_variable( 120 | self, key: str, val: T.Any, replace: bool = False 121 | ) -> "QueryBuilder": 122 | if key in self.variables: 123 | if not replace and self.variables[key] != val: 124 | raise QueryBuilderError( 125 | f"Key {key} already exists in variables so you cannot add it. " 126 | f"If you'd like to replace it, pass replace." 127 | ) 128 | self.variables[key] = val 129 | return self 130 | 131 | def add_variables( 132 | self, variables: dict[str, T.Any], replace: bool = False 133 | ) -> "QueryBuilder": 134 | """if there is an error, it does not save to the builder""" 135 | if not variables: 136 | return self 137 | if not replace: 138 | for k, v in variables.items(): 139 | if k in self.variables and v != self.variables[k]: 140 | raise QueryBuilderError( 141 | f"Key '{k}' already exists in variables so you cannot add it. " 142 | f"If you'd like to replace it, pass replace." 143 | ) 144 | self.variables.update(variables) 145 | return self 146 | 147 | def set_offset(self, offset: int | None, replace: bool = False) -> "QueryBuilder": 148 | if offset is None: 149 | return self 150 | if self.offset is not None: 151 | if not replace: 152 | raise QueryBuilderError( 153 | "An offset already exists. If you would like to replace it, pass in replace." 154 | ) 155 | self.offset = "OFFSET $offset" 156 | self.add_variable("offset", offset, replace=replace) 157 | return self 158 | 159 | def set_limit(self, limit: int | None, replace: bool = False) -> "QueryBuilder": 160 | if limit is None: 161 | return self 162 | if self.limit is not None: 163 | if not replace: 164 | raise QueryBuilderError( 165 | "A limit already exists. If you would like to replace it, pass in replace." 166 | ) 167 | self.limit = "LIMIT $limit" 168 | self.add_variable("limit", limit, replace=replace) 169 | return self 170 | 171 | def add_cte( 172 | self, cte_str: str, join_str: str, variables: dict[str, T.Any] | None = None 173 | ) -> "QueryBuilder": 174 | self.add_variables(variables) 175 | self.ctes.append(CTE(cte_str=cte_str, join_str=join_str)) 176 | return self 177 | 178 | def set_from( 179 | self, 180 | from_: str, 181 | variables: dict[str, T.Any] | None = None, 182 | replace_from: bool = False, 183 | replace_variables: bool = False, 184 | ) -> "QueryBuilder": 185 | if self.from_ and not replace_from: 186 | raise QueryBuilderError("from_ already exists.") 187 | self.add_variables(variables=variables, replace=replace_variables) 188 | pre_where, post_where = split_text_around_where(from_) 189 | self.from_ = pre_where 190 | if post_where: 191 | self.and_where(post_where) 192 | return self 193 | 194 | def and_where( 195 | self, 196 | where: str, 197 | variables: dict[str, T.Any] | None = None, 198 | replace_variables: bool = False, 199 | ) -> "QueryBuilder": 200 | self.add_variables(variables=variables, replace=replace_variables) 201 | if not self.where: 202 | self.where = where 203 | else: 204 | self.where = f"{self.where} AND {where}" 205 | return self 206 | 207 | def set_where( 208 | self, 209 | where: str, 210 | variables: dict[str, T.Any] | None = None, 211 | replace_where: bool = False, 212 | replace_variables: bool = False, 213 | ) -> "QueryBuilder": 214 | if self.where and not replace_where: 215 | raise QueryBuilderError("Where by already exists.") 216 | self.add_variables(variables=variables, replace=replace_variables) 217 | self.where = where 218 | return self 219 | 220 | def set_order_by( 221 | self, 222 | order_by: str, 223 | variables: dict[str, T.Any] | None = None, 224 | replace_order_by: bool = False, 225 | replace_variables: bool = False, 226 | ) -> "QueryBuilder": 227 | if self.order_by and not replace_order_by: 228 | raise QueryBuilderError("Order by already exists.") 229 | self.add_variables(variables=variables, replace=replace_variables) 230 | self.order_by = order_by 231 | return self 232 | 233 | def set_full_query_str( 234 | self, 235 | full_query_str: str, 236 | replace: bool = False, 237 | variables: dict[str, T.Any] | None = None, 238 | replace_variables: bool = False, 239 | ) -> "QueryBuilder": 240 | if self.full_query_str and not replace: 241 | raise QueryBuilderError("full_query_str already exists.") 242 | self.add_variables(variables=variables, replace=replace_variables) 243 | pattern_to_replace = "".join( 244 | random.choice(string.ascii_letters + string.digits) for _ in range(10) 245 | ) 246 | self.full_query_str = full_query_str.replace("$$", pattern_to_replace) 247 | self.pattern_to_replace = pattern_to_replace 248 | return self 249 | 250 | def build_subquery( 251 | self, 252 | name: str, 253 | qb: "QueryBuilder", 254 | path: tuple[str, ...], 255 | parent_table_alias: str, 256 | variables: dict, 257 | order_fields_alphabetically: bool, 258 | ) -> str: 259 | s, v = qb.build( 260 | parent_table_alias=parent_table_alias, 261 | path=path, 262 | order_fields_alphabetically=order_fields_alphabetically, 263 | ) 264 | for var_name, var_val in v.items(): 265 | if var_name in variables: 266 | if var_val is variables[var_name]: 267 | continue 268 | # must change the name for the child 269 | new_var_name = self.build_child_var_name( 270 | child_name=name, 271 | var_name=var_name, 272 | variables_to_use=variables, 273 | ) 274 | variables[new_var_name] = var_val 275 | # now, must regex the str to find this and replace it 276 | regex = re.compile(rf"\${var_name}(?!\w)") 277 | s = regex.sub(f"${new_var_name}", s) 278 | else: 279 | variables[var_name] = var_val 280 | 281 | s = f"'{name}', ({s})" 282 | return s 283 | 284 | def build_fields_s( 285 | self, 286 | new_path: tuple[str, ...], 287 | table_alias: str, 288 | order_fields_alphabetically: bool, 289 | ) -> tuple[list[str], dict[str, T.Any]]: 290 | variables = self.variables.copy() 291 | subquery_strs: list[str] = [ 292 | self.build_subquery( 293 | name=sel_sub.name, 294 | qb=sel_sub.qb, 295 | path=new_path, 296 | variables=variables, 297 | parent_table_alias=table_alias, 298 | order_fields_alphabetically=order_fields_alphabetically, 299 | ) 300 | for sel_sub in self.selections 301 | if isinstance(sel_sub, SelectionSub) 302 | ] 303 | selection_strs: list[str] = [ 304 | f"'{sel.name}', {sel.path}" 305 | for sel in self.selections 306 | if isinstance(sel, SelectionField) 307 | ] 308 | all_fields_strs = [*selection_strs, *subquery_strs] 309 | if order_fields_alphabetically: 310 | all_fields_strs.sort() 311 | if not all_fields_strs: 312 | raise QueryBuilderError(f"Query Builder {self=} has no fields.") 313 | 314 | return all_fields_strs, variables 315 | 316 | def build_filter_parts_s(self) -> str: 317 | filter_parts: list[str] = [] 318 | if self.where: 319 | filter_parts.append(f"WHERE {self.where}") 320 | if self.order_by: 321 | filter_parts.append(f"ORDER BY {self.order_by}") 322 | if self.offset: 323 | filter_parts.append(self.offset) 324 | if self.limit: 325 | filter_parts.append(self.limit) 326 | filter_parts_s = "\n".join(filter_parts) 327 | return filter_parts_s 328 | 329 | @staticmethod 330 | def replace_current_and_parent( 331 | s: str, table_alias: str, parent_table_alias: str | None 332 | ) -> str: 333 | s = s.replace("$current", table_alias) 334 | if parent_table_alias: 335 | s = s.replace("$parent", parent_table_alias) 336 | return s 337 | 338 | def get_top_level_ctes(self) -> list[CTE]: 339 | ctes = [cte for cte in self.ctes if cte.is_top_level] 340 | for sel_sub in self.selections: 341 | if isinstance(sel_sub, SelectionSub): 342 | ctes.extend(sel_sub.qb.get_top_level_ctes()) 343 | return ctes 344 | 345 | def build( 346 | self, 347 | parent_table_alias: str | None, 348 | path: tuple[str, ...] | None, 349 | order_fields_alphabetically: bool = True, 350 | is_count: bool | None = None, 351 | use_top_level_ctes: bool = True, 352 | ) -> tuple[str, dict[str, T.Any]]: 353 | is_count = is_count if is_count is not None else self.is_count 354 | if is_count: 355 | if self.limit is not None: 356 | raise QueryBuilderError("Cannot be is_count and have a limit.") 357 | if self.offset is not None: 358 | raise QueryBuilderError("Cannot be is_count and have an offset.") 359 | if path: 360 | new_path = (*path, self.table_name) 361 | else: 362 | new_path = (self.table_name,) 363 | if self.table_alias: 364 | table_alias = self.table_alias 365 | else: 366 | table_alias = "__".join(new_path).replace('"', "").replace(".", "__") 367 | if len(table_alias) > 55: 368 | table_alias = ( 369 | f"{table_alias[0:10]}{random_string(10)}{table_alias[-10:]}" 370 | ) 371 | if not path: 372 | if table_alias.lower() == self.table_name.lower().replace('"', ""): 373 | table_alias = f"_{table_alias}" 374 | strs_list, variables = self.build_fields_s( 375 | new_path=new_path, 376 | table_alias=table_alias, 377 | order_fields_alphabetically=order_fields_alphabetically, 378 | ) 379 | filter_parts_s = self.build_filter_parts_s() 380 | # now do from_ 381 | if not self.from_: 382 | self.from_ = "*FROM*" 383 | self.from_ = self.from_.replace( 384 | "*FROM*", f"FROM {self.table_name} {table_alias}" 385 | ) 386 | if not self.from_ or not self.from_.lower().startswith("from "): 387 | self.from_ = f"FROM {self.table_name} {table_alias} {self.from_}" 388 | 389 | # if this is the top level, use top level CTES. Otherwise, ignore them 390 | if use_top_level_ctes: 391 | ctes_to_use = self.get_top_level_ctes() 392 | else: 393 | ctes_to_use = [cte for cte in self.ctes if not cte.is_top_level] 394 | 395 | cte_str = ",\n".join([cte.cte_str for cte in ctes_to_use]) 396 | cte_join_str = "\n".join([cte.join_str for cte in ctes_to_use]) 397 | 398 | # now build the json objects by chunking the keys + vals at 50 399 | chunks = chunk_list(lst=strs_list, chunk_size=50) 400 | if len(chunks) == 1: 401 | json_obj_str = f'json_build_object({", ".join(chunks[0])})' 402 | else: 403 | json_obj_strs: list[str] = [] 404 | for chunk in chunks: 405 | json_obj_strs.append(f'jsonb_build_object({", ".join(chunk)})') 406 | json_obj_str = " || ".join(json_obj_strs) 407 | if is_count: 408 | json_obj_str = f"COUNT({json_obj_str})" 409 | s = f""" 410 | {cte_str} 411 | SELECT {json_obj_str} AS {table_alias}_json 412 | {self.from_} 413 | {cte_join_str} 414 | {filter_parts_s} 415 | """.strip() 416 | if self.cardinality == Cardinality.MANY: 417 | if is_count is not True: 418 | s = f""" 419 | SELECT COALESCE(json_agg({table_alias}_json), '[]'::json) AS {table_alias}_json_agg 420 | FROM ( 421 | {s} 422 | ) as {table_alias}_json_sub 423 | """.strip() 424 | if self.full_query_str: 425 | s = self.full_query_str.replace(self.pattern_to_replace, s) 426 | # now replace the values 427 | s = self.replace_current_and_parent( 428 | s=s, table_alias=table_alias, parent_table_alias=parent_table_alias 429 | ) 430 | return s, variables 431 | 432 | def build_root( 433 | self, 434 | format_sql: bool = False, 435 | order_fields_alphabetically: bool = False, 436 | parent_table_alias: str = None, 437 | path: tuple[str, ...] = None, 438 | driver: PostgresDriver = PostgresDriver.SQLALCHEMY, 439 | is_count: bool | None = None, 440 | ) -> tuple[str, dict[T.Any]]: 441 | rr = self.build( 442 | order_fields_alphabetically=order_fields_alphabetically, 443 | parent_table_alias=parent_table_alias, 444 | path=path, 445 | is_count=is_count, 446 | ) 447 | s, v = rr 448 | if v: 449 | if driver == PostgresDriver.SQLALCHEMY: 450 | s, variables = self.prepare_query_sqlalchemy(sql=s, params=v) 451 | elif driver == PostgresDriver.PSYCOPG3: 452 | s, variables = self.prepare_query_psycopg(sql=s, params=v) 453 | elif driver == PostgresDriver.ASYNCPG: 454 | s, variables = self.prepare_query_asyncpg(sql=s, params=v) 455 | else: 456 | raise QueryBuilderError(f"Unknown driver: {driver=}.") 457 | else: 458 | variables = {} 459 | if format_sql: 460 | s = sqlparse.format(s, reindent=True, keyword_case="upper") 461 | return s, variables 462 | 463 | @staticmethod 464 | def prepare_query_asyncpg( 465 | sql: str, params: dict[str, T.Any] 466 | ) -> tuple[str, list[T.Any]]: 467 | """ 468 | Generated by GPT4 469 | Converts a SQL string with named parameters (e.g., $variable) to a format 470 | compatible with asyncpg (using $1, $2, etc.), and returns the new SQL string 471 | and the list of values in the correct order. 472 | 473 | :param sql: Original SQL string with named parameters 474 | :param params: Dictionary of parameters 475 | :return: Tuple of (new_sql_string, list_of_values) 476 | """ 477 | 478 | # Extract the named parameters from the SQL string 479 | named_params = re.findall(r"\$(\w+)", sql) 480 | # Ensure that each parameter is unique 481 | unique_params = list(dict.fromkeys(named_params)) 482 | 483 | # Replace named parameters with positional parameters ($1, $2, etc.) 484 | for i, param in enumerate(unique_params, start=1): 485 | sql = sql.replace(f"${param}", f"${i}") 486 | 487 | # Create the list of values in the order they appear in the query 488 | values = [params[param] for param in unique_params] 489 | 490 | return sql, values 491 | 492 | @staticmethod 493 | def prepare_query_psycopg( 494 | sql: str, params: dict[str, T.Any] 495 | ) -> tuple[str, dict[str, T.Any]]: 496 | """ 497 | Converts a SQL string with named parameters (e.g., $variable) to a format 498 | compatible with psycopg (using %(variable)s), and returns the new SQL string 499 | and the dictionary of parameters. 500 | 501 | :param sql: Original SQL string with named parameters 502 | :param params: Dictionary of parameters 503 | :return: Tuple of (new_sql_string, dict_of_parameters) 504 | """ 505 | 506 | # Extract the named parameters from the SQL string 507 | named_params = re.findall(r"[\$:]([\w]+)", sql) 508 | 509 | # Replace named parameters with %(param)s 510 | for param in named_params: 511 | sql = sql.replace(f"${param}", f"%({param})s") 512 | # TODO be careful with this lower one 513 | sql = sql.replace(f":{param}", f"%({param})s") 514 | return sql, {**params} 515 | 516 | @staticmethod 517 | def prepare_query_sqlalchemy( 518 | sql: str, params: dict[str, T.Any] 519 | ) -> tuple[str, dict[str, T.Any]]: 520 | """ 521 | Converts a SQL string with named parameters (e.g., $variable) to a format 522 | compatible with sqlalchemy (using :variable), and returns the new SQL string 523 | and the dictionary of parameters. 524 | 525 | :param sql: Original SQL string with named parameters 526 | :param params: Dictionary of parameters 527 | :return: Tuple of (new_sql_string, dict_of_parameters) 528 | """ 529 | 530 | # Extract the named parameters from the SQL string 531 | named_params = re.findall(r"\$(\w+)", sql) 532 | 533 | # Replace named parameters with %(param)s 534 | for param in named_params: 535 | sql = sql.replace(f"${param}", f":{param}") 536 | 537 | # Create the dictionary of parameters to be used in the query 538 | return sql, {**params} 539 | 540 | def existing_sel(self, name: str) -> SelectionField | SelectionSub | None: 541 | for sel in self.selections: 542 | if name == sel.name: 543 | return sel 544 | return None 545 | 546 | def sel( 547 | self, name: str, path: str = None, variables: dict[str, T.Any] | None = None 548 | ) -> "QueryBuilder": 549 | if not path: 550 | path = f'$current."{name}"' 551 | self.add_variables(variables) 552 | self.selections.append( 553 | SelectionField(name=name, path=path, variables=variables) 554 | ) 555 | return self 556 | 557 | def sel_sub(self, name: str, qb: "QueryBuilder") -> "QueryBuilder": 558 | self.selections.append(SelectionSub(name=name, qb=qb)) 559 | return self 560 | 561 | def add_sel(self, sel: SelectionField | SelectionSub) -> "QueryBuilder": 562 | if isinstance(sel, SelectionField): 563 | return self.sel(name=sel.name, path=sel.path, variables=sel.variables) 564 | elif isinstance(sel, SelectionSub): 565 | return self.sel_sub(name=sel.name, qb=sel.qb) 566 | else: 567 | raise QueryBuilderError( 568 | f"Can only add selections for SelectionField or SelectionSub, not {type(sel)}" 569 | ) 570 | -------------------------------------------------------------------------------- /fastgql/scalars.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | from uuid import UUID 3 | import json 4 | from datetime import datetime, date, time 5 | from graphql import GraphQLScalarType, ValueNode 6 | from graphql.utilities import value_from_ast_untyped 7 | from pydantic_core import to_jsonable_python 8 | 9 | 10 | def serialize_datetime(value: datetime) -> str: 11 | return value.isoformat() 12 | 13 | 14 | def parse_datetime_value(value: T.Any) -> datetime: 15 | return datetime.fromisoformat(value) 16 | 17 | 18 | def parse_datetime_literal( 19 | value_node: ValueNode, variables: T.Optional[T.Dict[str, T.Any]] = None 20 | ) -> datetime: 21 | ast_value = value_from_ast_untyped(value_node, variables) 22 | return parse_datetime_value(ast_value) 23 | 24 | 25 | def serialize_date(value: date) -> str: 26 | return value.isoformat() 27 | 28 | 29 | def parse_date_value(value: T.Any) -> date: 30 | return date.fromisoformat(value) 31 | 32 | 33 | def parse_date_literal( 34 | value_node: ValueNode, variables: T.Optional[T.Dict[str, T.Any]] = None 35 | ) -> date: 36 | ast_value = value_from_ast_untyped(value_node, variables) 37 | return parse_date_value(ast_value) 38 | 39 | 40 | def serialize_time(value: time) -> str: 41 | return value.isoformat() 42 | 43 | 44 | def parse_time_value(value: T.Any) -> time: 45 | return time.fromisoformat(value) 46 | 47 | 48 | def parse_time_literal( 49 | value_node: ValueNode, variables: T.Optional[T.Dict[str, T.Any]] = None 50 | ) -> time: 51 | ast_value = value_from_ast_untyped(value_node, variables) 52 | return parse_time_value(ast_value) 53 | 54 | 55 | def serialize_uuid(value: UUID) -> str: 56 | return str(value) 57 | 58 | 59 | def parse_uuid_value(value: T.Any) -> UUID: 60 | return UUID(value) 61 | 62 | 63 | def parse_uuid_literal( 64 | value_node: ValueNode, variables: T.Optional[T.Dict[str, T.Any]] = None 65 | ) -> UUID: 66 | ast_value = value_from_ast_untyped(value_node, variables) 67 | return parse_uuid_value(ast_value) 68 | 69 | 70 | def serialize_json(value: dict) -> str: 71 | return json.dumps(to_jsonable_python(value)) 72 | 73 | 74 | def parse_json_value(value: T.Any) -> dict: 75 | return json.loads(value) 76 | 77 | 78 | def parse_json_literal( 79 | value_node: ValueNode, variables: T.Optional[T.Dict[str, T.Any]] = None 80 | ) -> dict: 81 | ast_value = value_from_ast_untyped(value_node, variables) 82 | return parse_json_value(ast_value) 83 | 84 | 85 | DateTimeScalar = GraphQLScalarType( 86 | name="DateTime", 87 | description="Datetime given as ISO.", 88 | serialize=serialize_datetime, 89 | parse_value=parse_datetime_value, 90 | parse_literal=parse_datetime_literal, 91 | ) 92 | 93 | UUIDScalar = GraphQLScalarType( 94 | name="UUID", 95 | description="UUID given as String.", 96 | serialize=serialize_uuid, 97 | parse_value=parse_uuid_value, 98 | parse_literal=parse_uuid_literal, 99 | ) 100 | DateScalar = GraphQLScalarType( 101 | name="Date", 102 | description="Date given as ISO.", 103 | serialize=serialize_date, 104 | parse_value=parse_date_value, 105 | parse_literal=parse_date_literal, 106 | ) 107 | TimeScalar = GraphQLScalarType( 108 | name="Time", 109 | description="Time given as ISO.", 110 | serialize=serialize_time, 111 | parse_value=parse_time_value, 112 | parse_literal=parse_time_literal, 113 | ) 114 | JSONScalar = GraphQLScalarType( 115 | name="JSON", 116 | description="JSON.", 117 | serialize=serialize_json, 118 | parse_value=parse_json_value, 119 | parse_literal=parse_json_literal, 120 | ) 121 | 122 | __all__ = ["UUIDScalar", "DateScalar", "TimeScalar", "DateTimeScalar", "JSONScalar"] 123 | -------------------------------------------------------------------------------- /fastgql/static/graphiql.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | FastGQL GraphiQL 5 | 13 | 43 | 44 | 49 | 54 | 55 | 60 | 61 | 67 | 68 | 74 | 75 | 76 | 77 |
Loading...
78 | 83 | 88 | 158 | 159 | 160 | -------------------------------------------------------------------------------- /fastgql/utils.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import json 3 | from fastgql.gql_ast import models as M 4 | 5 | 6 | def node_from_path( 7 | node: M.FieldNode, path: list[str], use_field_to_use: bool = False 8 | ) -> M.FieldNode | None: 9 | if not path: 10 | return node 11 | current_val = path.pop(0) 12 | children_q = [*node.children] 13 | while len(children_q) > 0: 14 | child = children_q.pop(0) 15 | if isinstance(child, M.FieldNode): 16 | name = ( 17 | child.name 18 | if not use_field_to_use 19 | else child.alias or child.display_name 20 | ) 21 | if name == current_val: 22 | return node_from_path(node=child, path=path) 23 | if isinstance(child, M.InlineFragmentNode): 24 | children_q.extend(child.children) 25 | 26 | return None 27 | 28 | 29 | def get_graphiql_html( 30 | subscription_enabled: bool = True, replace_variables: bool = True 31 | ) -> str: 32 | here = pathlib.Path(__file__).parent 33 | path = here / "static/graphiql.html" 34 | 35 | template = path.read_text(encoding="utf-8") 36 | 37 | if replace_variables: 38 | template = template.replace( 39 | "{{ SUBSCRIPTION_ENABLED }}", json.dumps(subscription_enabled) 40 | ) 41 | 42 | return template 43 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = pydantic.mypy 3 | 4 | follow_imports = silent 5 | warn_redundant_casts = True 6 | warn_unused_ignores = True 7 | disallow_any_generics = True 8 | check_untyped_defs = True 9 | no_implicit_reexport = True 10 | 11 | # for strict mypy: (this is the tricky one :-)) 12 | disallow_untyped_defs = True 13 | 14 | [pydantic-mypy] 15 | init_forbid_extra = True 16 | init_typed = True 17 | warn_required_dynamic_aliases = True 18 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fastgql" 3 | packages = [{ include = "fastgql" }] 4 | version = "0.24.4" 5 | description = "The easiest, fastest python GraphQL framework." 6 | authors = ["Jeremy Berman "] 7 | license = "MIT" 8 | readme = "README.md" 9 | homepage = "https://github.com/jerber/fastgql" 10 | repository = "https://github.com/jerber/fastgql" 11 | documentation = "https://jerber.github.io/fastgql/" 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Framework :: AsyncIO", 15 | "Intended Audience :: Developers", 16 | "Intended Audience :: Science/Research", 17 | "Intended Audience :: System Administrators", 18 | "License :: OSI Approved :: MIT License", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Topic :: Database", 22 | "Topic :: Database :: Database Engines/Servers", 23 | "Topic :: Internet", 24 | "Topic :: Internet :: WWW/HTTP :: HTTP Servers", 25 | "Topic :: Internet :: WWW/HTTP", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | "Typing :: Typed", 28 | ] 29 | 30 | [tool.poetry.dependencies] 31 | python = "^3.10" 32 | pydantic = "^2.7" 33 | fastapi = { extras = ["all"], version = "*" } 34 | edgedb = { version = "^1.7.0", optional = true } 35 | sqlparse = { version = "*", optional = true } 36 | graphql-core = "^3.2.3" 37 | devtools = "*" 38 | uvicorn = { extras = ["standard"], version = "*" } 39 | 40 | [tool.poetry.extras] 41 | edgedb = ["edgedb"] 42 | sql = ["sqlparse"] 43 | 44 | [tool.poetry.dev-dependencies] 45 | ruff = '*' 46 | mypy = '^1.5' 47 | mkdocs-material = { extras = ["imaging"], version = "^9.4.7" } 48 | mdx_include = '*' 49 | 50 | 51 | [build-system] 52 | requires = ["poetry-core"] 53 | build-backend = "poetry.core.masonry.api" -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerber/fastgql/064c1e846b1758a967d1464c50d7fef6b9f095b9/tests/__init__.py -------------------------------------------------------------------------------- /tests/for_docs/.gitignore: -------------------------------------------------------------------------------- 1 | .env -------------------------------------------------------------------------------- /tests/for_docs/build_data.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4, UUID 2 | 3 | people = [ 4 | {"id": uuid4(), "name": "Margot Robbie"}, 5 | {"id": uuid4(), "name": "Ryan Gosling"}, 6 | {"id": uuid4(), "name": "Jeremy Allen White"}, 7 | ] 8 | movies = [ 9 | {"id": uuid4(), "title": "Barbie", "release_year": 2023, "actors": people[0:2]} 10 | ] 11 | shows = [ 12 | { 13 | "id": uuid4(), 14 | "title": "Game Of Thrones", 15 | "seasons": [{"id": uuid4(), "number": x} for x in range(1, 9)], 16 | "actors": people[2:], 17 | } 18 | ] 19 | content = [*movies, *shows] 20 | content_by_person_id = {} 21 | for p in people: 22 | person_id = p["id"] 23 | filmography = [] 24 | for c in content: 25 | if person_id in [c_actor["id"] for c_actor in c["actors"]]: 26 | filmography.append(c) 27 | content_by_person_id[person_id] = filmography 28 | movies_by_id: dict[UUID, dict] = {m["id"]: m for m in movies} 29 | shows_by_id: dict[UUID, dict] = {s["id"]: s for s in shows} 30 | content_by_id: dict[UUID, dict] = {c["id"]: c for c in content} 31 | accounts = [{"id": uuid4(), "username": "jeremy", "watchlist": content}] 32 | accounts_by_username = {a["username"]: a for a in accounts} 33 | -------------------------------------------------------------------------------- /tests/for_docs/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastgql import GQL, build_router 3 | 4 | 5 | class User(GQL): 6 | name: str 7 | age: int 8 | 9 | 10 | class Query(GQL): 11 | @staticmethod 12 | def user() -> User: 13 | return User(name="Jeremy", age=27) 14 | 15 | 16 | router = build_router(query_models=[Query]) 17 | 18 | app = FastAPI() 19 | 20 | app.include_router(router, prefix="/graphql") 21 | -------------------------------------------------------------------------------- /tests/for_docs/movie_super_simple.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastgql import GQL, build_router 3 | 4 | 5 | class Actor(GQL): 6 | name: str 7 | 8 | 9 | class Movie(GQL): 10 | title: str 11 | release_year: int 12 | actors: list[Actor] 13 | 14 | 15 | class Query(GQL): 16 | def get_movies(self) -> list[Movie]: 17 | return [ 18 | Movie( 19 | title="Barbie", 20 | release_year=2023, 21 | actors=[Actor(name="Margot Robbie")], 22 | ) 23 | ] 24 | 25 | 26 | router = build_router(query_models=[Query]) 27 | 28 | app = FastAPI() 29 | 30 | app.include_router(router, prefix="/graphql") 31 | -------------------------------------------------------------------------------- /tests/for_docs/movies.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | from uuid import UUID 3 | from pydantic import TypeAdapter 4 | from fastapi import FastAPI 5 | from fastgql import GQL, GQLInterface, build_router 6 | from .build_data import ( 7 | accounts_by_username, 8 | content_by_person_id, 9 | content_by_id, 10 | shows_by_id, 11 | ) 12 | 13 | Contents = list[T.Union["Movie", "Show"]] 14 | 15 | 16 | class Account(GQL): 17 | id: UUID 18 | username: str 19 | 20 | def watchlist(self) -> Contents: 21 | """create a list of movies and shows""" 22 | watchlist_raw = accounts_by_username[self.username]["watchlist"] 23 | return TypeAdapter(Contents).validate_python(watchlist_raw) 24 | 25 | 26 | class Person(GQL): 27 | id: UUID 28 | name: str 29 | 30 | def filmography(self) -> Contents: 31 | return TypeAdapter(Contents).validate_python(content_by_person_id[self.id]) 32 | 33 | 34 | class Content(GQLInterface): 35 | id: UUID 36 | title: str 37 | 38 | def actors(self) -> list["Person"]: 39 | return [Person(**p) for p in content_by_id[self.id]["actors"]] 40 | 41 | 42 | class Movie(Content): 43 | id: UUID 44 | release_year: int 45 | 46 | 47 | class Show(Content): 48 | id: UUID 49 | 50 | def seasons(self) -> list["Season"]: 51 | return [Season(**s) for s in shows_by_id[self.id]["seasons"]] 52 | 53 | def num_seasons(self) -> int: 54 | return len(self.seasons()) 55 | 56 | 57 | class Season(GQL): 58 | id: UUID 59 | number: int 60 | show: "Show" 61 | 62 | 63 | class Query(GQL): 64 | @staticmethod 65 | async def account_by_username(username: str) -> Account: 66 | account = accounts_by_username[username] 67 | return Account(**account) 68 | 69 | 70 | router = build_router(query_models=[Query]) 71 | 72 | app = FastAPI() 73 | 74 | app.include_router(router, prefix="/graphql") 75 | -------------------------------------------------------------------------------- /tests/for_docs/movies_edgedb.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | import typing as T 4 | from uuid import UUID 5 | import edgedb 6 | from pydantic import TypeAdapter 7 | from fastapi import FastAPI 8 | from fastgql import GQL, GQLInterface, build_router 9 | from dotenv import load_dotenv 10 | 11 | load_dotenv() 12 | 13 | edgedb_client = edgedb.create_async_client() 14 | 15 | Contents = list[T.Union["Movie", "Show"]] 16 | 17 | 18 | async def query_required_single_json( 19 | name: str, query: str, **variables 20 | ) -> dict[str, T.Any]: 21 | start = time.time() 22 | res = json.loads( 23 | await edgedb_client.query_required_single_json(query=query, **variables) 24 | ) 25 | took_ms = round((time.time() - start) * 1_000, 2) 26 | print(f"[{name}] took {took_ms} ms") 27 | return res 28 | 29 | 30 | class Account(GQL): 31 | id: UUID 32 | username: str 33 | 34 | async def watchlist(self, limit: int) -> Contents: 35 | q = """select Account { 36 | watchlist: { id, title, release_year := [is Movie].release_year } limit $limit 37 | } filter .id = $id""" 38 | account_d = await query_required_single_json( 39 | name="account.watchlist", query=q, id=self.id, limit=limit 40 | ) 41 | return TypeAdapter(Contents).validate_python(account_d["watchlist"]) 42 | 43 | 44 | class Person(GQL): 45 | id: UUID 46 | name: str 47 | 48 | async def filmography(self) -> Contents: 49 | q = """select Person { 50 | filmography: { id, title, release_year := [is Movie].release_year } 51 | } filter .id = $id""" 52 | person_d = await query_required_single_json( 53 | name="person.filmography", query=q, id=self.id 54 | ) 55 | return TypeAdapter(Contents).validate_python(person_d["filmography"]) 56 | 57 | 58 | class Content(GQLInterface): 59 | id: UUID 60 | title: str 61 | 62 | async def actors(self) -> list["Person"]: 63 | q = """select Content { actors: { id, name } } filter .id = $id""" 64 | content_d = await query_required_single_json( 65 | name="content.actors", query=q, id=self.id 66 | ) 67 | return [Person(**p) for p in content_d["actors"]] 68 | 69 | 70 | class Movie(Content): 71 | release_year: int 72 | 73 | 74 | class Show(Content): 75 | async def seasons(self) -> list["Season"]: 76 | q = """select Show { season := .$id""" 77 | show_d = await query_required_single_json( 78 | name="show.seasons", query=q, id=self.id 79 | ) 80 | return [Season(**s) for s in show_d["season"]] 81 | 82 | async def num_seasons(self) -> int: 83 | q = """select Show { num_seasons } filter .id = $id""" 84 | show_d = await query_required_single_json( 85 | name="show.num_seasons", query=q, id=self.id 86 | ) 87 | return show_d["num_seasons"] 88 | 89 | 90 | class Season(GQL): 91 | id: UUID 92 | number: int 93 | 94 | async def show(self) -> Show: 95 | q = """select Season { show: { id, title } } filter .id = $id""" 96 | season_d = await query_required_single_json( 97 | name="season.show", query=q, id=self.id 98 | ) 99 | return Show(**season_d["show"]) 100 | 101 | 102 | class Query(GQL): 103 | @staticmethod 104 | async def account_by_username(username: str) -> Account: 105 | q = """select Account { id, username } filter .username = $username""" 106 | account_d = await query_required_single_json( 107 | name="account_by_username", query=q, username=username 108 | ) 109 | return Account(**account_d) 110 | 111 | 112 | router = build_router(query_models=[Query]) 113 | 114 | app = FastAPI() 115 | 116 | app.include_router(router, prefix="/graphql") 117 | -------------------------------------------------------------------------------- /tests/for_docs/movies_qb.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | import typing as T 4 | from uuid import UUID 5 | import edgedb 6 | from fastapi import FastAPI 7 | from fastgql import ( 8 | GQL, 9 | GQLInterface, 10 | build_router, 11 | Link, 12 | Property, 13 | get_qb, 14 | QueryBuilder, 15 | Depends, 16 | Info, 17 | node_from_path, 18 | ) 19 | from dotenv import load_dotenv 20 | 21 | load_dotenv() 22 | 23 | edgedb_client = edgedb.create_async_client() 24 | 25 | Contents = list[T.Union["Movie", "Show"]] 26 | 27 | 28 | def parse_raw_content(raw_content: list[dict, T.Any]) -> Contents: 29 | w_list: Contents = [] 30 | for item in raw_content: 31 | if item["typename"] == "default::Movie": 32 | if movie := item.get("Movie"): 33 | w_list.append(Movie(**movie)) 34 | elif item["typename"] == "default::Show": 35 | if show := item.get("Show"): 36 | w_list.append(Show(**show)) 37 | return w_list 38 | 39 | 40 | async def query_required_single_json( 41 | name: str, query: str, **variables 42 | ) -> dict[str, T.Any]: 43 | start = time.time() 44 | res = json.loads( 45 | await edgedb_client.query_required_single_json(query=query, **variables) 46 | ) 47 | took_ms = round((time.time() - start) * 1_000, 2) 48 | print(f"[{name}] took {took_ms} ms") 49 | return res 50 | 51 | 52 | class AccountPageInfo(GQL): 53 | has_next_page: bool 54 | has_previous_page: bool 55 | start_cursor: str | None 56 | end_cursor: str | None 57 | 58 | 59 | class AccountEdge(GQL): 60 | cursor: str 61 | node: "Account" 62 | 63 | 64 | class AccountConnection(GQL): 65 | page_info: AccountPageInfo 66 | edges: list[AccountEdge] 67 | total_count: int 68 | 69 | 70 | def update_watchlist(child_qb: QueryBuilder, limit: int) -> None: 71 | child_qb.set_limit(limit) 72 | 73 | 74 | class Account(GQL): 75 | def __init__(self, **data): 76 | super().__init__(**data) 77 | self._data = data 78 | 79 | id: T.Annotated[UUID, Property(db_name="id")] = None 80 | username: T.Annotated[str, Property(db_name="username")] = None 81 | 82 | async def watchlist( 83 | self, info: Info, limit: int 84 | ) -> T.Annotated[Contents, Link(db_name="watchlist", update_qbs=update_watchlist)]: 85 | return parse_raw_content(raw_content=self._data[info.path[-1]]) 86 | 87 | 88 | class Content(GQLInterface): 89 | def __init__(self, **data): 90 | super().__init__(**data) 91 | self._data = data 92 | 93 | id: T.Annotated[UUID, Property(db_name="id")] = None 94 | title: T.Annotated[str, Property(db_name="title")] = None 95 | 96 | async def actors( 97 | self, info: Info 98 | ) -> T.Annotated[list["Person"], Link(db_name="actors")]: 99 | return [Person(**p) for p in self._data[info.path[-1]]] 100 | 101 | 102 | class Movie(Content): 103 | release_year: T.Annotated[int, Property(db_name="release_year")] = None 104 | 105 | 106 | class Show(Content): 107 | num_seasons: T.Annotated[int, Property(db_name="num_seasons")] = None 108 | 109 | async def seasons( 110 | self, info: Info 111 | ) -> T.Annotated[list["Season"], Link(db_name=" T.Annotated[Show, Link(db_name="show")]: 124 | return Show(**self._data[info.path[-1]]) 125 | 126 | 127 | class Person(GQL): 128 | def __init__(self, **data): 129 | super().__init__(**data) 130 | self._data = data 131 | 132 | id: T.Annotated[UUID, Property(db_name="id")] = None 133 | name: T.Annotated[str, Property(db_name="name")] = None 134 | 135 | async def filmography( 136 | self, info: Info 137 | ) -> T.Annotated[Contents, Link(db_name="filmography")]: 138 | return parse_raw_content(raw_content=self._data[info.path[-1]]) 139 | 140 | 141 | AccountEdge.model_rebuild() 142 | 143 | 144 | class Query(GQL): 145 | @staticmethod 146 | async def account_by_username( 147 | username: str, qb: QueryBuilder = Depends(get_qb) 148 | ) -> Account: 149 | s, v = qb.build() 150 | q = f"""select Account {s} filter .username = $username""" 151 | print(q) 152 | account_d = await query_required_single_json( 153 | name="account_by_username", query=q, username=username, **v 154 | ) 155 | return Account(**account_d) 156 | 157 | @staticmethod 158 | async def account_connection( 159 | info: Info, 160 | *, 161 | before: str | None = None, 162 | after: str | None = None, 163 | first: int, 164 | ) -> AccountConnection: 165 | qb: QueryBuilder = await Account.qb_config.from_info( 166 | info=info, node=node_from_path(node=info.node, path=["edges", "node"]) 167 | ) 168 | qb.fields.add("username") 169 | variables = {"first": first} 170 | filter_list: list[str] = [] 171 | if before: 172 | filter_list.append(".username > $before") 173 | variables["before"] = before 174 | if after: 175 | filter_list.append(".username < $after") 176 | variables["after"] = after 177 | if filter_list: 178 | filter_s = f'filter {" and ".join(filter_list)} ' 179 | else: 180 | filter_s = "" 181 | qb.add_variables(variables, replace=False) 182 | s, v = qb.build() 183 | q = f""" 184 | with 185 | all_accounts := (select Account), 186 | _first := $first, 187 | accounts := (select all_accounts {filter_s}order by .username desc limit _first), 188 | select {{ 189 | total_count := count(all_accounts), 190 | accounts := accounts {s} 191 | }} 192 | """ 193 | connection_d = await query_required_single_json( 194 | name="account_connection", query=q, **v 195 | ) 196 | total_count = connection_d["total_count"] 197 | _accounts = [Account(**d) for d in connection_d["accounts"]] 198 | connection = AccountConnection( 199 | page_info=AccountPageInfo( 200 | has_next_page=len(_accounts) == first and total_count > first, 201 | has_previous_page=after is not None, 202 | start_cursor=_accounts[0].username if _accounts else None, 203 | end_cursor=_accounts[-1].username if _accounts else None, 204 | ), 205 | total_count=total_count, 206 | edges=[ 207 | AccountEdge(node=account, cursor=account.username) 208 | for account in _accounts 209 | ], 210 | ) 211 | return connection 212 | 213 | 214 | router = build_router(query_models=[Query]) 215 | 216 | app = FastAPI() 217 | 218 | app.include_router(router, prefix="/graphql") 219 | -------------------------------------------------------------------------------- /tests/for_docs/movies_simple.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID, uuid4 2 | from fastapi import FastAPI 3 | from fastgql import GQL, build_router 4 | 5 | 6 | class Account(GQL): # (4)! 7 | id: UUID 8 | username: str 9 | 10 | def watchlist(self) -> list["Movie"]: # (1)! 11 | # Usually we'd use a database to get the user's watchlist. For this example, it is hardcoded. 12 | return [ 13 | Movie(id=uuid4(), title="Barbie", release_year=2023), 14 | Movie(id=uuid4(), title="Oppenheimer", release_year=2023), 15 | ] 16 | 17 | def _secret_function(self) -> str: # (2)! 18 | return "this is not exposed!" 19 | 20 | 21 | class Person(GQL): 22 | id: UUID 23 | name: str 24 | 25 | def filmography(self) -> list["Movie"]: 26 | return [ 27 | Movie(id=uuid4(), title="Barbie", release_year=2023), 28 | Movie(id=uuid4(), title="Wolf of Wallstreet", release_year=2013), 29 | ] 30 | 31 | 32 | class Movie(GQL): 33 | id: UUID 34 | title: str 35 | release_year: int 36 | 37 | def actors(self) -> list["Person"]: 38 | return [ 39 | Person(id=uuid4(), name="Margot Robbie"), 40 | Person(id=uuid4(), name="Ryan Gosling"), 41 | ] 42 | 43 | 44 | class Query(GQL): 45 | def account_by_username(self, username: str) -> Account: # (5)! 46 | # Usually we'd use a database to get this account. For this example, it is hardcoded. 47 | return Account(id=uuid4(), username=username) 48 | 49 | 50 | router = build_router(query_models=[Query]) 51 | 52 | app = FastAPI() # (3)! 53 | 54 | app.include_router(router, prefix="/graphql") 55 | -------------------------------------------------------------------------------- /tests/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerber/fastgql/064c1e846b1758a967d1464c50d7fef6b9f095b9/tests/server/__init__.py -------------------------------------------------------------------------------- /tests/server/main.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | import time 3 | from fastapi import FastAPI, Response, Request 4 | from fastgql import build_router 5 | 6 | from tests.server.services.users.gql import Query as UserQuery, Mutation as UserMutation 7 | 8 | app = FastAPI() 9 | 10 | 11 | # register_profiling_middleware(app) 12 | 13 | 14 | @app.middleware("http") 15 | async def add_error_handling_and_process_time_header( 16 | request: Request, call_next: T.Any 17 | ) -> Response: 18 | start_time = time.time() 19 | response = await call_next(request) 20 | process_time = time.time() - start_time 21 | response.headers["X-Process-Time-Ms"] = str(process_time * 1_000) 22 | return response 23 | 24 | 25 | # QB.build_from_schema(schema, use_camel_case=CAMEL_CASE) 26 | # Access.build_access_levels_from_schema(schema, use_camel_case=CAMEL_CASE) 27 | 28 | router = build_router( 29 | query_models=[UserQuery], mutation_models=[UserMutation], use_camel_case=True 30 | ) 31 | # here, you can add dependencies for auth if you want 32 | app.include_router(router, prefix="/graphql") 33 | -------------------------------------------------------------------------------- /tests/server/services/users/gql.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import typing as T 3 | import uuid 4 | from uuid import UUID 5 | from pydantic import Field 6 | from fastgql.gql_models import GQL, GQLInput 7 | from fastgql import Depends, FieldNode 8 | 9 | from fastgql.query_builders.sql.logic import ( 10 | Link, 11 | Property, 12 | get_qb, 13 | QueryBuilder, 14 | Cardinality, 15 | ) 16 | from devtools import debug 17 | 18 | 19 | class User(GQL): 20 | sql_table_name: T.ClassVar[str] = '"User"' 21 | 22 | id: T.Annotated[UUID, Property(path="$current.id")] = Field( 23 | ..., description="ID for user." 24 | ) 25 | name: T.Annotated[str, Property(path="$current.name")] = None 26 | first_name: T.Annotated[ 27 | str, Property(path="split_part($current.name, ' ', 1)") 28 | ] = None 29 | slug: T.Annotated[str, Property(path="$current.slug")] = None 30 | 31 | def nickname(self) -> str: 32 | """ 33 | builds nickname 34 | :return: str 35 | """ 36 | return f"lil {self.name}" 37 | 38 | def artists( 39 | self, 40 | ) -> T.Annotated[ 41 | list["Artist"], 42 | Link( 43 | cardinality=Cardinality.MANY, 44 | from_='FROM "Artist.sellers" JOIN "Artist" $current ON "Artist.sellers".source = $current.id WHERE "Artist.sellers".target = $parent.id', 45 | ), 46 | ]: 47 | ... 48 | 49 | 50 | def update_qbs_bookings(child_qb: QueryBuilder) -> None: 51 | child_qb.and_where("status_v2 = 'confirmed'") 52 | 53 | 54 | def bookings_count_update_qb( 55 | qb: QueryBuilder, 56 | child_node: FieldNode, 57 | status: str, 58 | limit: int, 59 | ) -> None: 60 | qb.sel( 61 | name=child_node.alias or child_node.name, 62 | path=f'(SELECT count(*) FROM "Booking" WHERE "Booking".artist_id = $current.id AND status_v2 = $status LIMIT {limit})', 63 | variables={"status": status}, 64 | ) 65 | debug(qb, status, limit) 66 | 67 | 68 | class Artist(GQL): 69 | sql_table_name: T.ClassVar[str] = '"Artist"' 70 | 71 | id: T.Annotated[UUID, Property(path="$current.id")] = Field( 72 | ..., description="ID for artist." 73 | ) 74 | name: T.Annotated[str, Property(path="$current.name")] = None 75 | slug: T.Annotated[str, Property(path="$current.slug")] = None 76 | 77 | def bookings( 78 | self, 79 | ) -> T.Annotated[ 80 | list["Booking"], 81 | Link( 82 | from_='FROM "Booking" $current WHERE $current.artist_id = $parent.id', 83 | cardinality=Cardinality.MANY, 84 | update_qbs=update_qbs_bookings, 85 | ), 86 | ]: 87 | return [] 88 | 89 | bookings_count: T.Annotated[ 90 | int, 91 | Property( 92 | path='(SELECT count(*) FROM "Booking" WHERE "Booking".artist_id = $current.id)' 93 | ), 94 | ] = None 95 | 96 | # TODO, having non link or property, something else. It is a property that needs more flex for 97 | # updating the qb 98 | def bookings_count_filtered( 99 | self, status: str, limit: int 100 | ) -> T.Annotated[int, Property(update_qb=bookings_count_update_qb, path=None)]: 101 | return 9 102 | 103 | def sellers( 104 | self, 105 | ) -> T.Annotated[ 106 | list[User], 107 | Link( 108 | cardinality=Cardinality.MANY, 109 | from_='FROM "Artist.sellers" JOIN "User" $current ON "Artist.sellers".target = $current.id WHERE "Artist.sellers".source = $parent.id', 110 | ), 111 | ]: 112 | """ 113 | # this is what you want: 114 | SELECT json_build_object('sellers', 115 | (SELECT json_agg(Artist__User_json) AS Artist__User_json_agg 116 | FROM (SELECT json_build_object('id', Artist__User.id, 'name', Artist__User.name) AS Artist__User_json 117 | 118 | FROM "Artist.sellers" 119 | JOIN "User" Artist__User ON "Artist.sellers".target = Artist__User.id 120 | 121 | WHERE "Artist.sellers".source = _Artist.id) AS Artist__Booking_json_sub)) AS _Artist_json 122 | FROM "Artist" _Artist 123 | WHERE _Artist.slug = 'penningtonstationband' 124 | :return: 125 | """ 126 | ... 127 | 128 | 129 | class Booking(GQL): 130 | sql_table_name: T.ClassVar[str] = '"Booking"' 131 | 132 | id: T.Annotated[UUID, Property(path="$current.id")] = None 133 | start_time: T.Annotated[ 134 | datetime.datetime, Property(path="$current.start_time") 135 | ] = None 136 | 137 | 138 | class UserInput(GQLInput): 139 | name: str = None 140 | 141 | 142 | class Query(GQL): 143 | @staticmethod 144 | async def get_user() -> User: 145 | return User( 146 | id=uuid.uuid4(), name="Frank Stove", slug="frank", first_name="Frank" 147 | ) 148 | 149 | @staticmethod 150 | async def get_users( 151 | limit: int, offset: int, qb: QueryBuilder = Depends(get_qb) 152 | ) -> list[User]: 153 | # use this to test sql query builder 154 | s, v = qb.set_limit(limit).set_offset(offset).build_root(format_sql=True) 155 | print(s, v) 156 | return [] 157 | 158 | @staticmethod 159 | async def get_artist_by_slug( 160 | slug: str, qb: QueryBuilder = Depends(get_qb) 161 | ) -> Artist | None: 162 | s, v = qb.and_where("$current.slug = $slug", {"slug": slug}).build_root( 163 | format_sql=True 164 | ) 165 | print(s, v) 166 | return None 167 | 168 | 169 | class Mutation(GQL): 170 | @staticmethod 171 | async def create_user(input: UserInput) -> User: 172 | return User(id=uuid.uuid4(), name=input.name or "Paul") 173 | -------------------------------------------------------------------------------- /tests/server/start.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uvicorn 3 | 4 | if __name__ == "__main__": 5 | os.environ["DOPPLER_ENV"] = "1" 6 | os.environ["HOST"] = "0.0.0.0" 7 | os.environ["PORT"] = "8001" 8 | os.environ["STAGE"] = "local" 9 | reload = bool(int(os.getenv("RELOAD", 1))) 10 | uvicorn.run( 11 | "tests.server.main:app", 12 | host=os.environ["HOST"], 13 | port=int(os.environ["PORT"]), 14 | reload=reload, 15 | log_level="info", 16 | ) 17 | -------------------------------------------------------------------------------- /tests/sqlparse_testing.py: -------------------------------------------------------------------------------- 1 | import sqlparse 2 | from devtools import debug 3 | 4 | parsed = sqlparse.parse( 5 | 'select * from "Booking" WHERE "Booking".id = $1 ORDER BY "Booking".start_time LIMIT 10 OFFSET 1' 6 | ) 7 | debug(parsed) 8 | -------------------------------------------------------------------------------- /tests/start.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uvicorn 3 | 4 | if __name__ == "__main__": 5 | os.environ["DOPPLER_ENV"] = "1" 6 | os.environ["HOST"] = "0.0.0.0" 7 | os.environ["PORT"] = "8001" 8 | os.environ["STAGE"] = "local" 9 | reload = bool(int(os.getenv("RELOAD", 1))) 10 | uvicorn.run( 11 | "tests.for_docs.movies_edgedb:app", 12 | # "tests.for_docs.movies_edgedb:app", 13 | host=os.environ["HOST"], 14 | port=int(os.environ["PORT"]), 15 | reload=reload, 16 | log_level="info", 17 | ) 18 | --------------------------------------------------------------------------------