Skip to content

Commit fcb998f

Browse files
feat: Automated SQLAlchemy integration
1 parent f9a95a3 commit fcb998f

26 files changed

Lines changed: 3051 additions & 24 deletions

AGENTS.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,19 @@ hatch test benchmark --benchmark-storage=file://benchmark/results
5353
just coverage
5454
```
5555

56+
### Running Examples
57+
58+
Use `uv` to run examples from the `examples/` directory. Refer to the docstrings within each example file for specific commands.
59+
Every example should include an example command of running a particular example with uvicorn.
60+
```bash
61+
# Run a basic query example
62+
uv run examples/basic_query_example.py
63+
64+
# Run an ASGI example with uvicorn
65+
uv run --with "uvicorn[standard]" --with ariadne \
66+
uvicorn examples.basic_query_example:app --reload
67+
```
68+
5669
## Code Style Requirements
5770

5871
- **Python 3.10+** with type hints throughout
@@ -96,4 +109,6 @@ Follow [Conventional Commits](https://www.conventionalcommits.org/):
96109
2. Ensure test coverage meets the 90% minimum requirement
97110
3. Format code with `just fmt`
98111
4. Verify type hints with `just types`
99-
5. Write a clear commit message following the conventional commits format
112+
5. Ensure the documentation is up-to-date
113+
6. Write a clear commit message following the conventional commits format
114+

CHANGELOG.md

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,9 @@ All notable unreleased changes to this project will be documented in this file.
44

55
For released versions, see the [Releases](https://github.com/mirumee/ariadne/releases) page.
66

7-
## Unreleased
7+
## 1.1.1a33 (2026-05-06)
88

99
### ✨ New Features
10-
- Upgrade graphiql to 5.2.2
10+
- Automated SQLAlchemy integration
1111

12-
### 🐛 Bug Fixes
13-
- Adjust code to new `ty` checker (0.0.25)
14-
15-
### 📚 Documentation
16-
- Fix path to match docosaurus
17-
- Add decorator workaround note for subscriptions in middleware and extensions doc
1812

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Documentation is available [here](https://ariadnegraphql.org).
4242
- Loading schema from `.graphql`, `.gql`, and `.graphqls` files.
4343
- ASGI and WSGI support, with integrations for Django, FastAPI, Flask, and Starlette.
4444
- Opt-in automatic resolvers mapping between `camelCase123` and `snake_case_123`.
45+
- Automated integration with **SQLAlchemy 2.0** for zero-boilerplate resolvers and N+1 prevention.
4546
- [OpenTelemetry](https://opentelemetry.io/) extension for API monitoring.
4647
- Built-in [GraphiQL](https://github.com/graphql/graphiql) explorer for development and testing.
4748
- GraphQL syntax validation via `gql()` helper function.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
try:
2+
from .dataloaders import LoaderRegistry, SQLAlchemyDataLoader
3+
from .extension import SQLAlchemyDataLoaderExtension
4+
from .objects import SQLAlchemyObjectType
5+
from .query import SQLAlchemyQueryType
6+
from .types import LoadStrategy
7+
from .utils import auto_eager_load
8+
except ImportError as ex:
9+
raise ImportError(
10+
"SQLAlchemy integration requires the 'sqlalchemy' and 'aiodataloader' "
11+
"packages. Install them using 'pip install \"ariadne[sqlalchemy]\"'."
12+
) from ex
13+
14+
__all__ = [
15+
"SQLAlchemyDataLoaderExtension",
16+
"LoadStrategy",
17+
"LoaderRegistry",
18+
"SQLAlchemyObjectType",
19+
"SQLAlchemyQueryType",
20+
"SQLAlchemyDataLoader",
21+
"auto_eager_load",
22+
]
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import inspect
2+
import logging
3+
from collections import defaultdict
4+
from typing import Any
5+
6+
from aiodataloader import DataLoader
7+
from sqlalchemy import select, tuple_
8+
from sqlalchemy.ext.asyncio import AsyncSession
9+
from sqlalchemy.orm import RelationshipProperty, Session
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class SQLAlchemyDataLoader(DataLoader):
15+
"""
16+
DataLoader for SQLAlchemy relationships supporting:
17+
- Composite Keys
18+
- Many-to-Many (secondary tables)
19+
- Result grouping via SQL columns (optimized)
20+
"""
21+
22+
def __init__(
23+
self,
24+
session: Session | AsyncSession,
25+
relation_prop: RelationshipProperty,
26+
cache: bool = True,
27+
):
28+
super().__init__(cache=cache)
29+
self.session = session
30+
self.relation_prop = relation_prop
31+
self.target_model = relation_prop.mapper.class_
32+
self.is_list = relation_prop.uselist
33+
34+
# Identify local and remote columns (handles composite keys)
35+
if relation_prop.secondary is not None:
36+
self.local_cols = [
37+
lp.key
38+
for lp, rp in relation_prop.synchronize_pairs
39+
if lp.key is not None
40+
]
41+
self.remote_cols = [
42+
rp.key
43+
for lp, rp in relation_prop.synchronize_pairs
44+
if rp.key is not None
45+
]
46+
else:
47+
self.local_cols = [
48+
c.key for c in relation_prop.local_columns if c.key is not None
49+
]
50+
self.remote_cols = [
51+
c.key for c in relation_prop.remote_side if c.key is not None
52+
]
53+
54+
self.secondary = relation_prop.secondary
55+
56+
def get_query(self, keys: list[Any]):
57+
"""Builds query. Handles composite IN clause and M2M joins."""
58+
target_model = self.target_model
59+
stmt = select(target_model)
60+
61+
if self.secondary is not None:
62+
stmt = stmt.join(self.secondary)
63+
filter_cols = [self.secondary.c[k] for k in self.remote_cols]
64+
else:
65+
filter_cols = [getattr(target_model, k) for k in self.remote_cols]
66+
67+
# Add the filtering columns to the result to allow grouping
68+
stmt = stmt.add_columns(*filter_cols)
69+
70+
if len(filter_cols) > 1:
71+
stmt = stmt.where(tuple_(*filter_cols).in_(keys))
72+
else:
73+
# Flatten keys if they are single-element tuples
74+
flat_keys = [k[0] if isinstance(k, (list, tuple)) else k for k in keys]
75+
stmt = stmt.where(filter_cols[0].in_(flat_keys))
76+
77+
return stmt
78+
79+
async def batch_load_fn(self, keys: list[Any]) -> list[Any]:
80+
logger.debug(
81+
"SQLAlchemyRelationLoader: Fetching %s for %d parents",
82+
self.target_model.__name__,
83+
len(keys),
84+
)
85+
stmt = self.get_query(keys)
86+
87+
result = self.session.execute(stmt)
88+
if inspect.isawaitable(result):
89+
result = await result
90+
91+
rows = result.all() # type: ignore
92+
93+
num_filter_cols = len(self.remote_cols)
94+
grouped = defaultdict(list)
95+
96+
for row in rows:
97+
item = row[0]
98+
# The filter columns are appended after the model instance
99+
key_parts = row[1 : 1 + num_filter_cols]
100+
key = tuple(key_parts) if num_filter_cols > 1 else key_parts[0]
101+
grouped[key].append(item)
102+
103+
return [
104+
grouped[k] if self.is_list else (grouped[k][0] if grouped[k] else None)
105+
for k in keys
106+
]
107+
108+
109+
class LoaderRegistry:
110+
def __init__(self, session: Session | AsyncSession):
111+
self.session = session
112+
self._loaders: dict[
113+
tuple[RelationshipProperty, type[DataLoader]], DataLoader
114+
] = {}
115+
116+
def get_loader(
117+
self,
118+
relation_prop: RelationshipProperty,
119+
loader_class: type[SQLAlchemyDataLoader] = SQLAlchemyDataLoader,
120+
) -> DataLoader:
121+
key = (relation_prop, loader_class)
122+
if key not in self._loaders:
123+
self._loaders[key] = loader_class(self.session, relation_prop)
124+
return self._loaders[key]
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from typing import Any
2+
3+
from ...types import Extension
4+
from .dataloaders import LoaderRegistry
5+
6+
7+
class SQLAlchemyDataLoaderExtension(Extension):
8+
"""Ariadne extension that creates a per-request `LoaderRegistry`.
9+
10+
Wires the SQLAlchemy DataLoader fallback path automatically: at the start
11+
of each GraphQL request, reads the session from `context[session_key]`
12+
and writes a fresh `LoaderRegistry(session)` to `context[registry_key]`.
13+
14+
"""
15+
16+
def __init__(
17+
self,
18+
*,
19+
session_key: str = "session",
20+
registry_key: str = "loader_registry",
21+
):
22+
self.session_key = session_key
23+
self.registry_key = registry_key
24+
25+
def request_started(self, context: Any) -> None:
26+
context[self.registry_key] = LoaderRegistry(context[self.session_key])
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Callable
4+
from typing import Any, cast
5+
6+
from graphql import GraphQLObjectType, GraphQLSchema
7+
from sqlalchemy import select
8+
from sqlalchemy.orm import DeclarativeBase, RelationshipProperty, class_mapper
9+
10+
from ...objects import ObjectType
11+
from .dataloaders import LoaderRegistry
12+
from .types import LoadStrategy
13+
14+
15+
class SQLAlchemyObjectType(ObjectType):
16+
"""
17+
ObjectType specialized for SQLAlchemy models.
18+
Automatically binds resolvers for relationships using DataLoaders.
19+
"""
20+
21+
model: type[DeclarativeBase]
22+
aliases: dict[str, str]
23+
strategies: dict[str, LoadStrategy]
24+
max_depth: int
25+
_registry_key: str
26+
27+
def __init__(
28+
self,
29+
name: str,
30+
model: type[DeclarativeBase],
31+
*,
32+
aliases: dict[str, str] | Callable[[], dict[str, str]] | None = None,
33+
strategies: dict[str, LoadStrategy] | None = None,
34+
max_depth: int = 3,
35+
):
36+
super().__init__(name)
37+
self.model = model
38+
self.aliases = aliases() if callable(aliases) else (aliases or {}) # ty: ignore[call-top-callable]
39+
self.strategies = strategies or {}
40+
self.max_depth = max_depth
41+
42+
def bind_to_schema(self, schema: GraphQLSchema) -> None:
43+
"""Binds this `SQLAlchemyObjectType` to the GraphQL schema.
44+
45+
Auto-generates resolvers for the model's relationships and aliased
46+
columns, then delegates to `ObjectType.bind_to_schema` to wire them
47+
(along with any explicitly-set resolvers) onto the schema's fields.
48+
49+
The auto-resolvers must be registered before calling `super()` so
50+
they are included when the parent iterates `self._resolvers` to
51+
populate the GraphQL type's field `resolve` attributes.
52+
"""
53+
graphql_type = schema.type_map.get(self.name)
54+
self.validate_graphql_type(graphql_type)
55+
self._bind_auto_resolvers(cast(GraphQLObjectType, graphql_type))
56+
super().bind_to_schema(schema)
57+
58+
def get_base_query(self, info: Any, **kwargs: Any):
59+
"""
60+
Returns the base SQLAlchemy select statement for root queries.
61+
Can be overridden to apply default filters.
62+
"""
63+
return select(self.model)
64+
65+
def _bind_auto_resolvers(self, graphql_type: GraphQLObjectType) -> None:
66+
schema_fields = graphql_type.fields
67+
mapper = class_mapper(self.model)
68+
69+
for gql_field, db_attr in self.aliases.items():
70+
if gql_field not in schema_fields:
71+
continue
72+
if callable(db_attr):
73+
self.set_field(gql_field, db_attr)
74+
else:
75+
self.set_field(
76+
gql_field, lambda obj, *_, _attr=db_attr: getattr(obj, _attr)
77+
)
78+
79+
for relation in mapper.relationships:
80+
if relation.key not in schema_fields:
81+
continue
82+
if relation.key in self._resolvers:
83+
continue
84+
self.set_field(relation.key, self._create_relation_resolver(relation))
85+
86+
@staticmethod
87+
def get_loader_registry_from_context(context: Any) -> LoaderRegistry:
88+
"""Get the `LoaderRegistry` from the GraphQL context.
89+
90+
Override this method to customize how the registry is retrieved.
91+
"""
92+
try:
93+
return context["loader_registry"]
94+
except KeyError:
95+
raise RuntimeError(
96+
"LoaderRegistry not found in context under key 'loader_registry'"
97+
)
98+
99+
def _create_relation_resolver(self, relation: RelationshipProperty):
100+
async def resolve(obj: Any, info: Any, **kwargs: Any):
101+
# If the attribute is already loaded (e.g. via joinedload/selectinload),
102+
# return it
103+
if relation.key in obj.__dict__:
104+
return getattr(obj, relation.key)
105+
106+
loader_registry = self.get_loader_registry_from_context(info.context)
107+
108+
# Identify which column(s) on the current object connect it to the
109+
# target table. For a One-to-Many, this is usually a Foreign Key.
110+
local_relation_columns = [
111+
c.key for c in relation.local_columns if c.key is not None
112+
]
113+
114+
# Extract the actual database values from this specific object instance.
115+
join_values = tuple(getattr(obj, col) for col in local_relation_columns)
116+
117+
# If it's a standard single-column relationship, unwrap the tuple to just
118+
# the ID. If it's a composite key, keep the tuple.
119+
lookup_key = join_values[0] if len(join_values) == 1 else join_values
120+
121+
loader = loader_registry.get_loader(relation)
122+
return await loader.load(lookup_key)
123+
124+
return resolve

0 commit comments

Comments
 (0)