starspring 0.1.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,345 @@
1
+ """
2
+ Query method parser and SQL generator
3
+
4
+ Parses repository method names and generates SQL queries automatically.
5
+ Similar to Spring Data JPA's query derivation.
6
+ """
7
+
8
+ import re
9
+ from typing import List, Tuple, Any, Optional, Type
10
+ from enum import Enum
11
+
12
+
13
+ class QueryOperation(Enum):
14
+ """Query operation types"""
15
+ FIND = "find"
16
+ COUNT = "count"
17
+ DELETE = "delete"
18
+ EXISTS = "exists"
19
+
20
+
21
+ class QueryCondition(Enum):
22
+ """Query condition operators"""
23
+ EQUALS = ""
24
+ AND = "And"
25
+ OR = "Or"
26
+ GREATER_THAN = "GreaterThan"
27
+ LESS_THAN = "LessThan"
28
+ GREATER_THAN_EQUAL = "GreaterThanEqual"
29
+ LESS_THAN_EQUAL = "LessThanEqual"
30
+ LIKE = "Like"
31
+ CONTAINING = "Containing"
32
+ STARTING_WITH = "StartingWith"
33
+ ENDING_WITH = "EndingWith"
34
+ BETWEEN = "Between"
35
+ IN = "In"
36
+ NOT = "Not"
37
+ IS_NULL = "IsNull"
38
+ IS_NOT_NULL = "IsNotNull"
39
+ TRUE = "True"
40
+ FALSE = "False"
41
+
42
+
43
+ class QueryPart:
44
+ """Represents a part of a query"""
45
+
46
+ def __init__(
47
+ self,
48
+ field: str,
49
+ operator: QueryCondition,
50
+ connector: Optional[str] = None
51
+ ):
52
+ self.field = field
53
+ self.operator = operator
54
+ self.connector = connector # AND or OR
55
+
56
+
57
+ class ParsedQuery:
58
+ """Parsed query information"""
59
+
60
+ def __init__(
61
+ self,
62
+ operation: QueryOperation,
63
+ parts: List[QueryPart],
64
+ order_by: Optional[List[Tuple[str, str]]] = None
65
+ ):
66
+ self.operation = operation
67
+ self.parts = parts
68
+ self.order_by = order_by or []
69
+
70
+
71
+ class QueryMethodParser:
72
+ """
73
+ Parses repository method names into query components
74
+
75
+ Supports patterns like:
76
+ - findByName
77
+ - findByEmailAndActive
78
+ - findByAgeGreaterThan
79
+ - findByNameContaining
80
+ - countByActive
81
+ - deleteByEmail
82
+ - existsByUsername
83
+ """
84
+
85
+ # Regex patterns for parsing
86
+ OPERATION_PATTERN = r'^(find|count|delete|exists)By'
87
+ CONDITION_PATTERNS = {
88
+ 'GreaterThanEqual': r'GreaterThanEqual',
89
+ 'LessThanEqual': r'LessThanEqual',
90
+ 'GreaterThan': r'GreaterThan',
91
+ 'LessThan': r'LessThan',
92
+ 'Containing': r'Containing',
93
+ 'StartingWith': r'StartingWith',
94
+ 'EndingWith': r'EndingWith',
95
+ 'Between': r'Between',
96
+ 'Like': r'Like',
97
+ 'In': r'In',
98
+ 'IsNull': r'IsNull',
99
+ 'IsNotNull': r'IsNotNull',
100
+ 'True': r'True',
101
+ 'False': r'False',
102
+ 'Not': r'Not',
103
+ }
104
+
105
+ def parse(self, method_name: str) -> ParsedQuery:
106
+ """
107
+ Parse a repository method name into query components
108
+
109
+ Args:
110
+ method_name: Method name to parse
111
+
112
+ Returns:
113
+ ParsedQuery object
114
+
115
+ Raises:
116
+ ValueError: If method name cannot be parsed
117
+ """
118
+ # Extract operation
119
+ operation_match = re.match(self.OPERATION_PATTERN, method_name)
120
+ if not operation_match:
121
+ raise ValueError(f"Invalid query method name: {method_name}")
122
+
123
+ operation_str = operation_match.group(1)
124
+ operation = QueryOperation(operation_str)
125
+
126
+ # Remove operation prefix
127
+ remainder = method_name[len(operation_match.group(0)):]
128
+
129
+ # Check for OrderBy clause
130
+ order_by = []
131
+ if 'OrderBy' in remainder:
132
+ parts = remainder.split('OrderBy')
133
+ remainder = parts[0]
134
+ order_clause = parts[1]
135
+ order_by = self._parse_order_by(order_clause)
136
+
137
+ # Parse conditions
138
+ query_parts = self._parse_conditions(remainder)
139
+
140
+ return ParsedQuery(operation, query_parts, order_by)
141
+
142
+ def _parse_conditions(self, condition_str: str) -> List[QueryPart]:
143
+ """Parse condition string into query parts"""
144
+ parts = []
145
+
146
+ # Split by And/Or
147
+ tokens = re.split(r'(And|Or)', condition_str)
148
+
149
+ current_connector = None
150
+ i = 0
151
+ while i < len(tokens):
152
+ token = tokens[i]
153
+
154
+ if token in ['And', 'Or']:
155
+ current_connector = token.upper()
156
+ i += 1
157
+ continue
158
+
159
+ if not token:
160
+ i += 1
161
+ continue
162
+
163
+ # Parse this condition
164
+ field, operator = self._parse_single_condition(token)
165
+ parts.append(QueryPart(field, operator, current_connector))
166
+
167
+ i += 1
168
+
169
+ return parts
170
+
171
+ def _parse_single_condition(self, condition: str) -> Tuple[str, QueryCondition]:
172
+ """Parse a single condition into field and operator"""
173
+ # Check for operators
174
+ for op_name, op_pattern in self.CONDITION_PATTERNS.items():
175
+ if condition.endswith(op_name):
176
+ field = condition[:-len(op_name)]
177
+ return self._camel_to_snake(field), QueryCondition[op_name.upper().replace('THAN', '_THAN')]
178
+
179
+ # No operator means equals
180
+ return self._camel_to_snake(condition), QueryCondition.EQUALS
181
+
182
+ def _parse_order_by(self, order_str: str) -> List[Tuple[str, str]]:
183
+ """Parse OrderBy clause"""
184
+ orders = []
185
+ parts = re.split(r'(And)', order_str)
186
+
187
+ for part in parts:
188
+ if part == 'And' or not part:
189
+ continue
190
+
191
+ # Check for Asc/Desc
192
+ if part.endswith('Desc'):
193
+ field = part[:-4]
194
+ direction = 'DESC'
195
+ elif part.endswith('Asc'):
196
+ field = part[:-3]
197
+ direction = 'ASC'
198
+ else:
199
+ field = part
200
+ direction = 'ASC'
201
+
202
+ orders.append((self._camel_to_snake(field), direction))
203
+
204
+ return orders
205
+
206
+ def _camel_to_snake(self, name: str) -> str:
207
+ """Convert CamelCase to snake_case"""
208
+ s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
209
+ return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
210
+
211
+
212
+ class SQLQueryGenerator:
213
+ """
214
+ Generates SQL queries from parsed query information
215
+ """
216
+
217
+ def __init__(self, table_name: str):
218
+ self.table_name = table_name
219
+
220
+ def generate(self, parsed_query: ParsedQuery) -> Tuple[str, List[str]]:
221
+ """
222
+ Generate SQL query from parsed query
223
+
224
+ Args:
225
+ parsed_query: Parsed query information
226
+
227
+ Returns:
228
+ Tuple of (SQL query string, list of parameter placeholders)
229
+ """
230
+ if parsed_query.operation == QueryOperation.FIND:
231
+ return self._generate_select(parsed_query)
232
+ elif parsed_query.operation == QueryOperation.COUNT:
233
+ return self._generate_count(parsed_query)
234
+ elif parsed_query.operation == QueryOperation.DELETE:
235
+ return self._generate_delete(parsed_query)
236
+ elif parsed_query.operation == QueryOperation.EXISTS:
237
+ return self._generate_exists(parsed_query)
238
+
239
+ raise ValueError(f"Unsupported operation: {parsed_query.operation}")
240
+
241
+ def _generate_select(self, parsed_query: ParsedQuery) -> Tuple[str, List[str]]:
242
+ """Generate SELECT query"""
243
+ sql = f"SELECT * FROM {self.table_name}"
244
+ params = []
245
+
246
+ if parsed_query.parts:
247
+ where_clause, params = self._build_where_clause(parsed_query.parts)
248
+ sql += f" WHERE {where_clause}"
249
+
250
+ if parsed_query.order_by:
251
+ order_parts = [f"{field} {direction}" for field, direction in parsed_query.order_by]
252
+ sql += f" ORDER BY {', '.join(order_parts)}"
253
+
254
+ return sql, params
255
+
256
+ def _generate_count(self, parsed_query: ParsedQuery) -> Tuple[str, List[str]]:
257
+ """Generate COUNT query"""
258
+ sql = f"SELECT COUNT(*) FROM {self.table_name}"
259
+ params = []
260
+
261
+ if parsed_query.parts:
262
+ where_clause, params = self._build_where_clause(parsed_query.parts)
263
+ sql += f" WHERE {where_clause}"
264
+
265
+ return sql, params
266
+
267
+ def _generate_delete(self, parsed_query: ParsedQuery) -> Tuple[str, List[str]]:
268
+ """Generate DELETE query"""
269
+ sql = f"DELETE FROM {self.table_name}"
270
+ params = []
271
+
272
+ if parsed_query.parts:
273
+ where_clause, params = self._build_where_clause(parsed_query.parts)
274
+ sql += f" WHERE {where_clause}"
275
+
276
+ return sql, params
277
+
278
+ def _generate_exists(self, parsed_query: ParsedQuery) -> Tuple[str, List[str]]:
279
+ """Generate EXISTS query"""
280
+ sql = f"SELECT EXISTS(SELECT 1 FROM {self.table_name}"
281
+ params = []
282
+
283
+ if parsed_query.parts:
284
+ where_clause, params = self._build_where_clause(parsed_query.parts)
285
+ sql += f" WHERE {where_clause}"
286
+
287
+ sql += ")"
288
+ return sql, params
289
+
290
+ def _build_where_clause(self, parts: List[QueryPart]) -> Tuple[str, List[str]]:
291
+ """Build WHERE clause from query parts"""
292
+ conditions = []
293
+ params = []
294
+
295
+ for part in parts:
296
+ condition, part_params = self._build_condition(part)
297
+
298
+ if part.connector and conditions:
299
+ conditions.append(f" {part.connector} {condition}")
300
+ else:
301
+ conditions.append(condition)
302
+
303
+ params.extend(part_params)
304
+
305
+ return ''.join(conditions), params
306
+
307
+ def _build_condition(self, part: QueryPart) -> Tuple[str, List[str]]:
308
+ """Build a single condition"""
309
+ field = part.field
310
+ operator = part.operator
311
+
312
+ if operator == QueryCondition.EQUALS:
313
+ return f"{field} = ?", [field]
314
+ elif operator == QueryCondition.GREATER_THAN:
315
+ return f"{field} > ?", [field]
316
+ elif operator == QueryCondition.LESS_THAN:
317
+ return f"{field} < ?", [field]
318
+ elif operator == QueryCondition.GREATER_THAN_EQUAL:
319
+ return f"{field} >= ?", [field]
320
+ elif operator == QueryCondition.LESS_THAN_EQUAL:
321
+ return f"{field} <= ?", [field]
322
+ elif operator == QueryCondition.LIKE:
323
+ return f"{field} LIKE ?", [field]
324
+ elif operator == QueryCondition.CONTAINING:
325
+ return f"{field} LIKE ?", [field] # Will add % in executor
326
+ elif operator == QueryCondition.STARTING_WITH:
327
+ return f"{field} LIKE ?", [field] # Will add % in executor
328
+ elif operator == QueryCondition.ENDING_WITH:
329
+ return f"{field} LIKE ?", [field] # Will add % in executor
330
+ elif operator == QueryCondition.BETWEEN:
331
+ return f"{field} BETWEEN ? AND ?", [field, field]
332
+ elif operator == QueryCondition.IN:
333
+ return f"{field} IN (?)", [field]
334
+ elif operator == QueryCondition.IS_NULL:
335
+ return f"{field} IS NULL", []
336
+ elif operator == QueryCondition.IS_NOT_NULL:
337
+ return f"{field} IS NOT NULL", []
338
+ elif operator == QueryCondition.TRUE:
339
+ return f"{field} = TRUE", []
340
+ elif operator == QueryCondition.FALSE:
341
+ return f"{field} = FALSE", []
342
+ elif operator == QueryCondition.NOT:
343
+ return f"{field} != ?", [field]
344
+
345
+ raise ValueError(f"Unsupported operator: {operator}")
@@ -0,0 +1,324 @@
1
+ """
2
+ Repository pattern implementation
3
+
4
+ Provides Spring Boot-style repository pattern for data access with automatic query generation.
5
+ """
6
+
7
+ from typing import TypeVar, Generic, List, Optional, Any, Type
8
+ from starspring.data.orm_gateway import get_orm_gateway
9
+ import inspect
10
+ import logging
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ T = TypeVar('T')
15
+
16
+
17
+ class Repository(Generic[T]):
18
+ """
19
+ Generic repository base class
20
+
21
+ Provides CRUD operations for entities.
22
+ Similar to Spring Data JPA's Repository interface.
23
+
24
+ Example:
25
+ @Repository
26
+ class UserRepository(Repository[User]):
27
+ def find_by_email(self, email: str) -> Optional[User]:
28
+ # Custom query method
29
+ pass
30
+ """
31
+
32
+ def __init__(self, entity_class: Type[T]):
33
+ """
34
+ Initialize repository
35
+
36
+ Args:
37
+ entity_class: The entity class this repository manages
38
+ """
39
+ self.entity_class = entity_class
40
+ self._gateway_instance = None # Lazy initialization
41
+
42
+ @property
43
+ def _gateway(self):
44
+ """Lazy-load the ORM gateway"""
45
+ if self._gateway_instance is None:
46
+ self._gateway_instance = get_orm_gateway()
47
+ return self._gateway_instance
48
+
49
+ async def save(self, entity: T) -> T:
50
+ """
51
+ Save an entity
52
+
53
+ Args:
54
+ entity: Entity to save
55
+
56
+ Returns:
57
+ Saved entity
58
+ """
59
+ return await self._gateway.save(entity)
60
+
61
+ async def find_by_id(self, id: Any) -> Optional[T]:
62
+ """
63
+ Find an entity by ID
64
+
65
+ Args:
66
+ id: Entity ID
67
+
68
+ Returns:
69
+ Entity if found, None otherwise
70
+ """
71
+ return await self._gateway.find_by_id(self.entity_class, id)
72
+
73
+ async def find_all(self) -> List[T]:
74
+ """
75
+ Find all entities
76
+
77
+ Returns:
78
+ List of all entities
79
+ """
80
+ return await self._gateway.find_all(self.entity_class)
81
+
82
+ async def delete(self, entity: T) -> None:
83
+ """
84
+ Delete an entity
85
+
86
+ Args:
87
+ entity: Entity to delete
88
+ """
89
+ await self._gateway.delete(entity)
90
+
91
+ async def delete_by_id(self, id: Any) -> bool:
92
+ """
93
+ Delete an entity by ID
94
+
95
+ Args:
96
+ id: Entity ID
97
+
98
+ Returns:
99
+ True if deleted, False if not found
100
+ """
101
+ entity = await self.find_by_id(id)
102
+ if entity:
103
+ await self.delete(entity)
104
+ return True
105
+ return False
106
+
107
+ async def update(self, entity: T) -> T:
108
+ """
109
+ Update an entity
110
+
111
+ Args:
112
+ entity: Entity to update
113
+
114
+ Returns:
115
+ Updated entity
116
+ """
117
+ return await self._gateway.update(entity)
118
+
119
+ async def exists(self, id: Any) -> bool:
120
+ """
121
+ Check if an entity exists
122
+
123
+ Args:
124
+ id: Entity ID
125
+
126
+ Returns:
127
+ True if exists, False otherwise
128
+ """
129
+ return await self._gateway.exists(self.entity_class, id)
130
+
131
+ async def count(self) -> int:
132
+ """
133
+ Count all entities
134
+
135
+ Returns:
136
+ Number of entities
137
+ """
138
+ entities = await self.find_all()
139
+ return len(entities)
140
+
141
+
142
+ class CrudRepository(Repository[T]):
143
+ """
144
+ Alias for Repository
145
+
146
+ Provides Spring Data JPA-style naming.
147
+ """
148
+ pass
149
+
150
+
151
+ class StarRepository(Repository[T]):
152
+ """
153
+ StarSpring repository with additional batch operations and automatic query generation
154
+
155
+ Extends the base Repository with:
156
+ - Batch operations for multiple entities
157
+ - Automatic query method execution (findBy*, countBy*, deleteBy*, existsBy*)
158
+
159
+ Example:
160
+ @Repository
161
+ class UserRepository(StarRepository[User]):
162
+ # These methods are auto-implemented!
163
+ async def findByEmail(self, email: str) -> Optional[User]:
164
+ pass
165
+
166
+ async def findByActive(self, active: bool) -> List[User]:
167
+ pass
168
+ """
169
+
170
+ def __init__(self, entity_class: Type[T] = None):
171
+ """
172
+ Initialize StarRepository
173
+
174
+ Args:
175
+ entity_class: Optional entity class. If not provided, will be extracted from generic type.
176
+ """
177
+ # If entity_class not provided, try to extract from generic type
178
+ if entity_class is None:
179
+ entity_class = self._extract_entity_class()
180
+
181
+ # Call parent constructor
182
+ super().__init__(entity_class)
183
+
184
+ def _extract_entity_class(self) -> Type[T]:
185
+ """
186
+ Extract entity class from generic type parameter
187
+
188
+ Returns:
189
+ Entity class type
190
+ """
191
+ import typing
192
+
193
+ # Get the class's __orig_bases__ to find the generic parameter
194
+ for base in getattr(self.__class__, '__orig_bases__', []):
195
+ # Check if this is a generic type
196
+ if hasattr(base, '__origin__') and hasattr(base, '__args__'):
197
+ # Get the first type argument (the entity class)
198
+ if base.__args__:
199
+ return base.__args__[0]
200
+
201
+ # Fallback: raise error if we can't determine entity class
202
+ raise ValueError(
203
+ f"Could not determine entity class for {self.__class__.__name__}. "
204
+ f"Please specify it as a generic parameter: class MyRepo(StarRepository[MyEntity])"
205
+ )
206
+
207
+ def __getattribute__(self, name: str):
208
+ """
209
+ Magic method to intercept attribute access
210
+
211
+ Automatically implements query methods based on method name.
212
+ """
213
+ # Get the attribute normally first
214
+ try:
215
+ attr = object.__getattribute__(self, name)
216
+
217
+ # If it's a method and it's defined (not just 'pass'), return it
218
+ if callable(attr) and hasattr(attr, '__func__'):
219
+ # Check if method has actual implementation (more than just 'pass')
220
+ source = inspect.getsource(attr.__func__)
221
+ if 'pass' not in source or len(source.strip().split('\n')) > 3:
222
+ return attr
223
+ except AttributeError:
224
+ pass
225
+
226
+ # Check if this is a query method that should be auto-implemented
227
+ if name.startswith(('findBy', 'countBy', 'deleteBy', 'existsBy')):
228
+ logger.debug(f"Auto-implementing query method: {name}")
229
+ return self._create_query_method(name)
230
+
231
+ # Default behavior for everything else
232
+ return object.__getattribute__(self, name)
233
+
234
+ def _create_query_method(self, method_name: str):
235
+ """
236
+ Create a query method dynamically
237
+
238
+ Args:
239
+ method_name: Name of the method (e.g., 'findByEmail')
240
+
241
+ Returns:
242
+ Async function that executes the query
243
+ """
244
+ from starspring.data.query_builder import QueryMethodParser, SQLQueryGenerator, QueryOperation
245
+
246
+ # Parse the method name
247
+ try:
248
+ parser = QueryMethodParser()
249
+ parsed_query = parser.parse(method_name)
250
+ except Exception as e:
251
+ logger.error(f"Failed to parse query method '{method_name}': {e}")
252
+ raise ValueError(f"Invalid query method name: {method_name}")
253
+
254
+ # Get table name from entity metadata
255
+ if hasattr(self.entity_class, '_entity_metadata'):
256
+ table_name = self.entity_class._entity_metadata.table_name
257
+ else:
258
+ # Fallback: convert class name to snake_case
259
+ import re
260
+ name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', self.entity_class.__name__)
261
+ table_name = re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower()
262
+
263
+ # Generate SQL
264
+ generator = SQLQueryGenerator(table_name)
265
+ sql, param_names = generator.generate(parsed_query)
266
+
267
+ # Create the async function
268
+ async def query_executor(*args, **kwargs):
269
+ """Auto-generated query executor"""
270
+ # Build parameter dictionary
271
+ params = {}
272
+ for i, param_name in enumerate(param_names):
273
+ if i < len(args):
274
+ params[param_name] = args[i]
275
+ elif param_name in kwargs:
276
+ params[param_name] = kwargs[param_name]
277
+
278
+ # Execute the query through the gateway
279
+ result = await self._gateway.execute_query(
280
+ sql,
281
+ params,
282
+ self.entity_class,
283
+ parsed_query.operation
284
+ )
285
+
286
+ return result
287
+
288
+ return query_executor
289
+
290
+ async def save_all(self, entities: List[T]) -> List[T]:
291
+ """
292
+ Save multiple entities
293
+
294
+ Args:
295
+ entities: List of entities to save
296
+
297
+ Returns:
298
+ List of saved entities
299
+ """
300
+ result = []
301
+ for entity in entities:
302
+ saved = await self.save(entity)
303
+ result.append(saved)
304
+ return result
305
+
306
+ async def delete_all(self, entities: List[T]) -> None:
307
+ """
308
+ Delete multiple entities
309
+
310
+ Args:
311
+ entities: List of entities to delete
312
+ """
313
+ for entity in entities:
314
+ await self.delete(entity)
315
+
316
+ async def delete_all_by_id(self, ids: List[Any]) -> None:
317
+ """
318
+ Delete multiple entities by ID
319
+
320
+ Args:
321
+ ids: List of entity IDs
322
+ """
323
+ for id in ids:
324
+ await self.delete_by_id(id)