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.
@@ -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
+ ]
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any