sqlalchemy-load 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlalchemy_load/__init__.py +18 -0
- sqlalchemy_load/cache.py +20 -0
- sqlalchemy_load/errors.py +19 -0
- sqlalchemy_load/generator.py +196 -0
- sqlalchemy_load/parser.py +111 -0
- sqlalchemy_load-0.2.0.dist-info/METADATA +206 -0
- sqlalchemy_load-0.2.0.dist-info/RECORD +8 -0
- sqlalchemy_load-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""SQLAlchemy Load - Generate query optimization options from simplified syntax."""
|
|
2
|
+
|
|
3
|
+
from .cache import ModelMetadata
|
|
4
|
+
from .errors import FieldNotFoundError, ParseError, RelationshipNotFoundError
|
|
5
|
+
from .generator import LoadGenerator
|
|
6
|
+
from .parser import FieldSelection, parse_query_string
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"LoadGenerator",
|
|
12
|
+
"FieldSelection",
|
|
13
|
+
"ModelMetadata",
|
|
14
|
+
"parse_query_string",
|
|
15
|
+
"ParseError",
|
|
16
|
+
"FieldNotFoundError",
|
|
17
|
+
"RelationshipNotFoundError",
|
|
18
|
+
]
|
sqlalchemy_load/cache.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Metadata caching utilities for SQLAlchemy models."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Type
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ModelMetadata:
|
|
9
|
+
"""Cached metadata for a SQLAlchemy model.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
columns: Set of column names on the model
|
|
13
|
+
relationships: Set of relationship names on the model
|
|
14
|
+
primary_keys: Set of primary key column names
|
|
15
|
+
relationship_targets: Mapping of relationship name to target model class
|
|
16
|
+
"""
|
|
17
|
+
columns: set[str]
|
|
18
|
+
relationships: set[str]
|
|
19
|
+
primary_keys: set[str]
|
|
20
|
+
relationship_targets: dict[str, Type]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Custom exceptions for sqlalchemy-load-generator."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ParseError(Exception):
|
|
5
|
+
"""Raised when the query string syntax is invalid."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FieldNotFoundError(Exception):
|
|
11
|
+
"""Raised when a specified field does not exist on the model."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RelationshipNotFoundError(Exception):
|
|
17
|
+
"""Raised when a specified relationship does not exist on the model."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""LoadGenerator core class."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Type
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.orm import DeclarativeBase, load_only, selectinload
|
|
8
|
+
|
|
9
|
+
from .cache import ModelMetadata
|
|
10
|
+
from .errors import FieldNotFoundError, RelationshipNotFoundError
|
|
11
|
+
from .parser import FieldSelection, parse_query_string_cached
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LoadGenerator:
|
|
15
|
+
"""
|
|
16
|
+
Generate SQLAlchemy query optimization options from simplified field selection syntax.
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
generator = LoadGenerator(Base) # Pass DeclarativeBase
|
|
20
|
+
options = generator.generate(User, "{ id name posts { title } }")
|
|
21
|
+
stmt = select(User).options(*options)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, base_class: type[DeclarativeBase]):
|
|
25
|
+
"""
|
|
26
|
+
Initialize LoadGenerator with a SQLAlchemy DeclarativeBase.
|
|
27
|
+
|
|
28
|
+
Preloads metadata for all models in the registry for better performance.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
base_class: SQLAlchemy DeclarativeBase class
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
TypeError: If base_class is not a valid DeclarativeBase
|
|
35
|
+
"""
|
|
36
|
+
self._base_class = base_class
|
|
37
|
+
self._metadata_cache: dict[type, ModelMetadata] = {}
|
|
38
|
+
self._options_cache: dict[str, list[Any]] = {}
|
|
39
|
+
self._preload_all_metadata()
|
|
40
|
+
|
|
41
|
+
def _is_declarative_base(self, cls: type) -> bool:
|
|
42
|
+
"""Check if class is a DeclarativeBase subclass."""
|
|
43
|
+
try:
|
|
44
|
+
registry = getattr(cls, 'registry', None)
|
|
45
|
+
return registry is not None and hasattr(registry, 'mappers')
|
|
46
|
+
except AttributeError:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
def _preload_all_metadata(self) -> None:
|
|
50
|
+
"""Preload metadata for all models in the registry."""
|
|
51
|
+
if not self._is_declarative_base(self._base_class):
|
|
52
|
+
raise TypeError(
|
|
53
|
+
f"{self._base_class.__name__} is not a valid DeclarativeBase. "
|
|
54
|
+
"Please pass the Base class that your models inherit from."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
for mapper in self._base_class.registry.mappers:
|
|
58
|
+
model_class = mapper.class_
|
|
59
|
+
self._metadata_cache[model_class] = ModelMetadata(
|
|
60
|
+
columns={col.key for col in mapper.columns},
|
|
61
|
+
relationships={rel.key for rel in mapper.relationships},
|
|
62
|
+
primary_keys={pk.key for pk in mapper.primary_key},
|
|
63
|
+
relationship_targets={
|
|
64
|
+
rel.key: rel.mapper.class_
|
|
65
|
+
for rel in mapper.relationships
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def _get_metadata(self, model_class: type) -> ModelMetadata:
|
|
70
|
+
"""Get cached metadata for a model."""
|
|
71
|
+
if model_class not in self._metadata_cache:
|
|
72
|
+
raise TypeError(
|
|
73
|
+
f"Model {model_class.__name__} not found in registry. "
|
|
74
|
+
f"Make sure it inherits from {self._base_class.__name__}."
|
|
75
|
+
)
|
|
76
|
+
return self._metadata_cache[model_class]
|
|
77
|
+
|
|
78
|
+
def generate(self, model_class: type, query_string: str) -> list[Any]:
|
|
79
|
+
"""
|
|
80
|
+
Generate SQLAlchemy query options from field selection string.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
model_class: SQLAlchemy model class to generate options for
|
|
84
|
+
query_string: Simplified field selection syntax, e.g. "{ id name posts { title } }"
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
List of SQLAlchemy options that can be passed to .options(*options)
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
ParseError: If the query string syntax is invalid
|
|
91
|
+
FieldNotFoundError: If a specified field doesn't exist
|
|
92
|
+
RelationshipNotFoundError: If a specified relationship doesn't exist
|
|
93
|
+
"""
|
|
94
|
+
# Check cache
|
|
95
|
+
cache_key = self._make_cache_key(model_class, query_string)
|
|
96
|
+
if cache_key in self._options_cache:
|
|
97
|
+
return self._options_cache[cache_key]
|
|
98
|
+
|
|
99
|
+
# Parse and build
|
|
100
|
+
selection = parse_query_string_cached(query_string)
|
|
101
|
+
options = self._build_options(model_class, selection)
|
|
102
|
+
|
|
103
|
+
# Cache and return
|
|
104
|
+
self._options_cache[cache_key] = options
|
|
105
|
+
return options
|
|
106
|
+
|
|
107
|
+
def _make_cache_key(self, model_class: type, query_string: str) -> str:
|
|
108
|
+
"""Create a cache key for the generate result."""
|
|
109
|
+
return f"{model_class.__module__}.{model_class.__name__}:{query_string}"
|
|
110
|
+
|
|
111
|
+
def _build_options(self, model_class: type, selection: FieldSelection) -> list[Any]:
|
|
112
|
+
"""
|
|
113
|
+
Recursively build SQLAlchemy options from a FieldSelection.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
model_class: The SQLAlchemy model class for this level
|
|
117
|
+
selection: Parsed field selection
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
List of SQLAlchemy options
|
|
121
|
+
"""
|
|
122
|
+
options = []
|
|
123
|
+
metadata = self._get_metadata(model_class)
|
|
124
|
+
|
|
125
|
+
# Validate and separate fields from relationships
|
|
126
|
+
valid_fields = set()
|
|
127
|
+
for field_name in selection.fields:
|
|
128
|
+
if field_name in metadata.relationships:
|
|
129
|
+
# User forgot to add braces for relationship
|
|
130
|
+
raise RelationshipNotFoundError(
|
|
131
|
+
f"'{field_name}' is a relationship, use '{field_name} {{ ... }}' syntax"
|
|
132
|
+
)
|
|
133
|
+
if field_name not in metadata.columns:
|
|
134
|
+
raise FieldNotFoundError(
|
|
135
|
+
f"Field '{field_name}' does not exist on {model_class.__name__}"
|
|
136
|
+
)
|
|
137
|
+
valid_fields.add(field_name)
|
|
138
|
+
|
|
139
|
+
# Validate relationships
|
|
140
|
+
for rel_name in selection.relationships:
|
|
141
|
+
if rel_name not in metadata.relationships:
|
|
142
|
+
if rel_name in metadata.columns:
|
|
143
|
+
raise FieldNotFoundError(
|
|
144
|
+
f"'{rel_name}' is a column, not a relationship"
|
|
145
|
+
)
|
|
146
|
+
raise RelationshipNotFoundError(
|
|
147
|
+
f"Relationship '{rel_name}' does not exist on {model_class.__name__}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# 1. Build load_only for scalar fields (with primary key)
|
|
151
|
+
if valid_fields:
|
|
152
|
+
columns = self._ensure_primary_key(model_class, valid_fields, metadata)
|
|
153
|
+
options.append(load_only(*columns))
|
|
154
|
+
|
|
155
|
+
# 2. Build selectinload for relationships (recursive)
|
|
156
|
+
for rel_name, nested_selection in selection.relationships.items():
|
|
157
|
+
target_model = metadata.relationship_targets[rel_name]
|
|
158
|
+
rel_attr = getattr(model_class, rel_name)
|
|
159
|
+
|
|
160
|
+
nested_options = self._build_options(target_model, nested_selection)
|
|
161
|
+
loader = selectinload(rel_attr).options(*nested_options)
|
|
162
|
+
options.append(loader)
|
|
163
|
+
|
|
164
|
+
return options
|
|
165
|
+
|
|
166
|
+
def _ensure_primary_key(
|
|
167
|
+
self,
|
|
168
|
+
model_class: type,
|
|
169
|
+
field_names: set[str],
|
|
170
|
+
metadata: ModelMetadata
|
|
171
|
+
) -> list[Any]:
|
|
172
|
+
"""
|
|
173
|
+
Ensure primary key columns are included in load_only.
|
|
174
|
+
|
|
175
|
+
SQLAlchemy needs primary keys to properly construct objects.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
model_class: SQLAlchemy model class
|
|
179
|
+
field_names: Set of field names to load
|
|
180
|
+
metadata: Cached model metadata
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
List of column attributes for load_only
|
|
184
|
+
"""
|
|
185
|
+
columns = []
|
|
186
|
+
|
|
187
|
+
# Add primary keys first
|
|
188
|
+
for pk_name in sorted(metadata.primary_keys):
|
|
189
|
+
columns.append(getattr(model_class, pk_name))
|
|
190
|
+
|
|
191
|
+
# Add other requested fields
|
|
192
|
+
for field_name in sorted(field_names):
|
|
193
|
+
if field_name not in metadata.primary_keys:
|
|
194
|
+
columns.append(getattr(model_class, field_name))
|
|
195
|
+
|
|
196
|
+
return columns
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Parser for simplified field selection syntax."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .errors import ParseError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class FieldSelection:
|
|
15
|
+
"""Represents a parsed field selection."""
|
|
16
|
+
|
|
17
|
+
fields: set[str]
|
|
18
|
+
relationships: dict[str, FieldSelection]
|
|
19
|
+
|
|
20
|
+
def __eq__(self, other: Any) -> bool:
|
|
21
|
+
if not isinstance(other, FieldSelection):
|
|
22
|
+
return False
|
|
23
|
+
return self.fields == other.fields and self.relationships == other.relationships
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_query_string(query_string: str) -> FieldSelection:
|
|
27
|
+
"""
|
|
28
|
+
Parse a simplified field selection syntax.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
query_string: Field selection like "{ id name posts { title } }"
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
FieldSelection with fields and nested relationships
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
ParseError: If the syntax is invalid
|
|
38
|
+
"""
|
|
39
|
+
tokens = _tokenize(query_string)
|
|
40
|
+
if not tokens:
|
|
41
|
+
raise ParseError("Empty query string")
|
|
42
|
+
|
|
43
|
+
result, _ = _parse_selection(tokens, 0)
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _tokenize(query_string: str) -> list[str]:
|
|
48
|
+
"""Tokenize the query string into meaningful tokens."""
|
|
49
|
+
# Match braces, identifiers, and skip whitespace/commas
|
|
50
|
+
pattern = r'(\{|\}|[a-zA-Z_][a-zA-Z0-9_]*)'
|
|
51
|
+
tokens = re.findall(pattern, query_string)
|
|
52
|
+
return tokens
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_selection(tokens: list[str], index: int) -> tuple[FieldSelection, int]:
|
|
56
|
+
"""
|
|
57
|
+
Parse a selection set starting at the given index.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Tuple of (FieldSelection, next_index)
|
|
61
|
+
"""
|
|
62
|
+
if index >= len(tokens) or tokens[index] != '{':
|
|
63
|
+
raise ParseError(f"Expected '{{' at position {index}")
|
|
64
|
+
|
|
65
|
+
index += 1 # Skip opening brace
|
|
66
|
+
|
|
67
|
+
fields: set[str] = set()
|
|
68
|
+
relationships: dict[str, FieldSelection] = {}
|
|
69
|
+
|
|
70
|
+
while index < len(tokens) and tokens[index] != '}':
|
|
71
|
+
token = tokens[index]
|
|
72
|
+
|
|
73
|
+
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', token):
|
|
74
|
+
raise ParseError(f"Invalid token '{token}' at position {index}")
|
|
75
|
+
|
|
76
|
+
# Look ahead to see if this is a relationship (followed by {)
|
|
77
|
+
if index + 1 < len(tokens) and tokens[index + 1] == '{':
|
|
78
|
+
# It's a relationship
|
|
79
|
+
nested_selection, index = _parse_selection(tokens, index + 1)
|
|
80
|
+
relationships[token] = nested_selection
|
|
81
|
+
else:
|
|
82
|
+
# It's a field
|
|
83
|
+
fields.add(token)
|
|
84
|
+
index += 1
|
|
85
|
+
|
|
86
|
+
if index >= len(tokens):
|
|
87
|
+
raise ParseError("Missing closing brace '}'")
|
|
88
|
+
|
|
89
|
+
# index is now at the closing brace
|
|
90
|
+
index += 1 # Skip closing brace
|
|
91
|
+
|
|
92
|
+
if not fields and not relationships:
|
|
93
|
+
raise ParseError("Empty selection set")
|
|
94
|
+
|
|
95
|
+
return FieldSelection(fields=fields, relationships=relationships), index
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@lru_cache(maxsize=None)
|
|
99
|
+
def parse_query_string_cached(query_string: str) -> FieldSelection:
|
|
100
|
+
"""
|
|
101
|
+
Parse and cache the result. Queries are often repeated.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
query_string: Field selection like "{ id name posts { title } }"
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
FieldSelection with fields and nested relationships
|
|
108
|
+
"""
|
|
109
|
+
return parse_query_string(query_string)
|
|
110
|
+
|
|
111
|
+
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlalchemy-load
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Generate SQLAlchemy query optimization options from simplified field selection syntax
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# SQLAlchemy Load Generator
|
|
14
|
+
|
|
15
|
+
Generate SQLAlchemy query optimization options (`selectinload` + `load_only`) from simplified field selection syntax.
|
|
16
|
+
|
|
17
|
+
## Why This Library?
|
|
18
|
+
|
|
19
|
+
SQLAlchemy's query options (`selectinload`, `joinedload`, `load_only`) are powerful but **painful to write**, especially with nested relationships.
|
|
20
|
+
|
|
21
|
+
### The Problem
|
|
22
|
+
|
|
23
|
+
**1. Verbose nested syntax**
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
# Loading User -> Posts -> Comments requires deep nesting
|
|
27
|
+
stmt = select(User).options(
|
|
28
|
+
selectinload(User.posts).options(
|
|
29
|
+
load_only(Post.id, Post.title),
|
|
30
|
+
selectinload(Post.comments).options(
|
|
31
|
+
load_only(Comment.id, Comment.content)
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**2. Coupled with query logic**
|
|
38
|
+
|
|
39
|
+
You must decide what to load at query time, mixing data requirements with query construction. Different API endpoints need different loading strategies, leading to duplicated query code.
|
|
40
|
+
|
|
41
|
+
**3. Dynamic composition is awkward**
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
# Conditionally adding options requires extra logic
|
|
45
|
+
options = []
|
|
46
|
+
if need_posts:
|
|
47
|
+
options.append(selectinload(User.posts))
|
|
48
|
+
if need_comments:
|
|
49
|
+
options.append(selectinload(User.posts).selectinload(Post.comments))
|
|
50
|
+
stmt = select(User).options(*options)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**4. Easy to cause N+1 or over-fetching**
|
|
54
|
+
|
|
55
|
+
- Forget `selectinload` → N+1 queries
|
|
56
|
+
- Load unnecessary fields → wasted memory
|
|
57
|
+
|
|
58
|
+
### The Solution
|
|
59
|
+
|
|
60
|
+
This library provides a **declarative syntax** similar to GraphQL:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
# Before: verbose, nested, error-prone
|
|
64
|
+
stmt = select(User).options(
|
|
65
|
+
selectinload(User.posts).options(
|
|
66
|
+
load_only(Post.id, Post.title),
|
|
67
|
+
selectinload(Post.comments).options(
|
|
68
|
+
load_only(Comment.id, Comment.content)
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# After: clean, declarative, optimized
|
|
74
|
+
generator = LoadGenerator(Base)
|
|
75
|
+
options = generator.generate(User, "{ id name posts { title comments { content } } }")
|
|
76
|
+
stmt = select(User).options(*options)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Installation
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install sqlalchemy-load
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Usage
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from sqlalchemy import select
|
|
89
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
90
|
+
from sqlalchemy_load import LoadGenerator
|
|
91
|
+
|
|
92
|
+
# Initialize with your DeclarativeBase
|
|
93
|
+
generator = LoadGenerator(Base)
|
|
94
|
+
|
|
95
|
+
# Generate options using simplified syntax
|
|
96
|
+
options = generator.generate(User, "{ id name posts { title comments { content } } }")
|
|
97
|
+
|
|
98
|
+
# Use with SQLAlchemy query
|
|
99
|
+
stmt = select(User).options(*options)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Syntax
|
|
103
|
+
|
|
104
|
+
The simplified syntax is similar to GraphQL but without commas:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
{ field1 field2 relationship { nested_field } }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- Fields are space-separated
|
|
111
|
+
- Relationships use `{ }` for nested selection
|
|
112
|
+
- Commas are optional and ignored
|
|
113
|
+
|
|
114
|
+
### Examples
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
# Simple fields
|
|
118
|
+
generator.generate(User, "{ id name email }")
|
|
119
|
+
|
|
120
|
+
# Nested relationships
|
|
121
|
+
generator.generate(User, "{ id posts { title content } }")
|
|
122
|
+
|
|
123
|
+
# Deeply nested
|
|
124
|
+
generator.generate(User, "{ id posts { title comments { content author } } }")
|
|
125
|
+
|
|
126
|
+
# Multiple relationships
|
|
127
|
+
generator.generate(User, "{ name posts { title } profile { bio } }")
|
|
128
|
+
|
|
129
|
+
# Different models with same generator
|
|
130
|
+
generator.generate(Post, "{ title content author { name } }")
|
|
131
|
+
generator.generate(Comment, "{ content post { title } }")
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## API
|
|
135
|
+
|
|
136
|
+
### `LoadGenerator(base_class)`
|
|
137
|
+
|
|
138
|
+
Create a generator with a SQLAlchemy `DeclarativeBase`. Preloads metadata for all models in the registry for optimal performance.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
142
|
+
|
|
143
|
+
class Base(DeclarativeBase):
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
generator = LoadGenerator(Base)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### `generator.generate(model_class, query_string) -> list`
|
|
150
|
+
|
|
151
|
+
Generate SQLAlchemy options from a query string.
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
options = generator.generate(User, "{ id name posts { title } }")
|
|
155
|
+
stmt = select(User).options(*options)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Features
|
|
159
|
+
|
|
160
|
+
- **Preloaded metadata**: All model metadata is cached at initialization for fast lookups
|
|
161
|
+
- **Result caching**: Same query returns cached result, avoiding redundant computation
|
|
162
|
+
- **Parse caching**: Query string parsing is cached with `lru_cache`
|
|
163
|
+
- **Automatic primary key inclusion**: Primary keys are always included in `load_only`
|
|
164
|
+
- **Relationship detection**: Automatically detects SQLAlchemy relationships
|
|
165
|
+
- **Nested loading**: Recursively generates `selectinload` with nested `load_only`
|
|
166
|
+
- **Error handling**: Clear errors for invalid fields, relationships, or syntax
|
|
167
|
+
|
|
168
|
+
## Error Handling
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from sqlalchemy_load import (
|
|
172
|
+
LoadGenerator,
|
|
173
|
+
ParseError,
|
|
174
|
+
FieldNotFoundError,
|
|
175
|
+
RelationshipNotFoundError,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
generator = LoadGenerator(Base)
|
|
179
|
+
|
|
180
|
+
# Syntax error
|
|
181
|
+
try:
|
|
182
|
+
generator.generate(User, "{ id name") # Missing closing brace
|
|
183
|
+
except ParseError as e:
|
|
184
|
+
print(f"Syntax error: {e}")
|
|
185
|
+
|
|
186
|
+
# Field doesn't exist
|
|
187
|
+
try:
|
|
188
|
+
generator.generate(User, "{ nonexistent }")
|
|
189
|
+
except FieldNotFoundError as e:
|
|
190
|
+
print(f"Field not found: {e}")
|
|
191
|
+
|
|
192
|
+
# Relationship doesn't exist
|
|
193
|
+
try:
|
|
194
|
+
generator.generate(User, "{ notarelationship { id } }")
|
|
195
|
+
except RelationshipNotFoundError as e:
|
|
196
|
+
print(f"Relationship not found: {e}")
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Requirements
|
|
200
|
+
|
|
201
|
+
- Python >= 3.10
|
|
202
|
+
- SQLAlchemy >= 2.0
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
sqlalchemy_load/__init__.py,sha256=uk-Mx4ToKl-xNWx5EeNjbPXp9GkaPgXhl8yFyoUP_24,493
|
|
2
|
+
sqlalchemy_load/cache.py,sha256=Zo4jIS5uSks4-r-e0wTTtdIi18Rztbnv3s7jPCjiERY,587
|
|
3
|
+
sqlalchemy_load/errors.py,sha256=1ZQ1MrPu_mMCZ7BbILkuwxoYLYn20-ZrtUo4k_KzdhQ,404
|
|
4
|
+
sqlalchemy_load/generator.py,sha256=Jpi-ZbLuuLMdf2DtARLOFydoDe6VBkqKmdx7FzVIoUI,7380
|
|
5
|
+
sqlalchemy_load/parser.py,sha256=76QFlDoXCsKir6ODFd5rGRoy_djlQ0J4G8uM81LwXnY,3137
|
|
6
|
+
sqlalchemy_load-0.2.0.dist-info/METADATA,sha256=85lShGZIIA1GYPUY_ojprpyhPd6GbdbWDfnNh8bg8Go,5338
|
|
7
|
+
sqlalchemy_load-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
sqlalchemy_load-0.2.0.dist-info/RECORD,,
|