sqlobjects 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.
sqlobjects/signals.py ADDED
@@ -0,0 +1,464 @@
1
+ import functools
2
+ import inspect
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import Any, TypeVar
7
+
8
+ from sqlalchemy import event
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+
12
+ __all__ = [
13
+ "Operation",
14
+ "SignalContext",
15
+ "SignalMixin",
16
+ "emit_signals",
17
+ "event",
18
+ ]
19
+
20
+ F = TypeVar("F", bound=Callable[..., Any])
21
+
22
+
23
+ class Operation(Enum):
24
+ """Enumeration of database operation types for signal handling.
25
+
26
+ This enum defines the types of database operations that can trigger
27
+ signals in the SQLObjects system, allowing models to respond to
28
+ lifecycle events.
29
+
30
+ Values:
31
+ CREATE: Create operations for new model instances
32
+ UPDATE: Update operations on existing model instances
33
+ DELETE: Delete operations on individual instances or bulk deletions
34
+ SAVE: Generic save operations (create or update, for backward compatibility)
35
+
36
+ Examples:
37
+ >>> # Used in signal context for create operation
38
+ >>> context = SignalContext(
39
+ ... operation=Operation.CREATE, session=session, model_class=User, instance=user_instance
40
+ ... )
41
+ >>> # Used in signal context for update operation
42
+ >>> context = SignalContext(
43
+ ... operation=Operation.UPDATE, session=session, model_class=User, instance=user_instance
44
+ ... )
45
+ """
46
+
47
+ CREATE = "create"
48
+ UPDATE = "update"
49
+ DELETE = "delete"
50
+ SAVE = "save"
51
+
52
+
53
+ @dataclass
54
+ class SignalContext:
55
+ """Context object containing information about a database operation for signal handlers.
56
+
57
+ This class provides all the necessary information about a database operation
58
+ to signal handlers, including the operation type, affected data, and session
59
+ information.
60
+
61
+ Attributes:
62
+ operation: Type of database operation being performed
63
+ session: Database session used for the operation
64
+ model_class: Model class involved in the operation
65
+ instance: Specific model instance (for single-instance operations)
66
+ affected_count: Number of rows affected (for bulk operations)
67
+ update_data: Data being updated (for update operations)
68
+
69
+ Examples:
70
+ >>> # Single instance save operation
71
+ >>> context = SignalContext(operation=Operation.SAVE, session=session, model_class=User, instance=user)
72
+ >>> # Bulk update operation
73
+ >>> context = SignalContext(
74
+ ... operation=Operation.UPDATE,
75
+ ... session=session,
76
+ ... model_class=User,
77
+ ... affected_count=10,
78
+ ... update_data={"status": "active"},
79
+ ... )
80
+ """
81
+
82
+ operation: Operation
83
+ session: AsyncSession
84
+ model_class: Any # Target model class (for both single and batch operations)
85
+ instance: Any | None = None # Instance object for single instance operations
86
+ affected_count: int | None = None # Number of rows affected by batch operations
87
+ update_data: dict[str, Any] | None = None # Data for update operations
88
+ actual_operation: Operation | None = None # Actual operation for SAVE (CREATE or UPDATE)
89
+
90
+ @property
91
+ def is_bulk(self) -> bool:
92
+ """Check if this is a bulk operation affecting multiple records.
93
+
94
+ Returns:
95
+ True if this is a bulk operation, False for single-instance operations
96
+
97
+ Examples:
98
+ >>> # Bulk operation context
99
+ >>> context = SignalContext(operation=Operation.UPDATE, session=session, model_class=User)
100
+ >>> context.is_bulk # True
101
+ >>> # Single instance context
102
+ >>> context = SignalContext(operation=Operation.SAVE, session=session, model_class=User, instance=user)
103
+ >>> context.is_bulk # False
104
+ """
105
+ return self.instance is None
106
+
107
+ @property
108
+ def is_single(self) -> bool:
109
+ """Check if this is a single-instance operation.
110
+
111
+ Returns:
112
+ True if this is a single-instance operation, False for bulk operations
113
+
114
+ Examples:
115
+ >>> # Single instance context
116
+ >>> context = SignalContext(operation=Operation.SAVE, session=session, model_class=User, instance=user)
117
+ >>> context.is_single # True
118
+ >>> # Bulk operation context
119
+ >>> context = SignalContext(operation=Operation.UPDATE, session=session, model_class=User)
120
+ >>> context.is_single # False
121
+ """
122
+ return self.instance is not None
123
+
124
+
125
+ class SignalMixin:
126
+ """Mixin class that provides signal handling capabilities to model classes.
127
+
128
+ This mixin enables models to define signal handlers that are automatically
129
+ called before and after database operations. It supports both synchronous
130
+ and asynchronous signal handlers.
131
+
132
+ Signal Handler Methods:
133
+ Instance-level signals (single record operations):
134
+ - before_create(context): Called before create operations
135
+ - after_create(context): Called after create operations
136
+ - before_update(context): Called before update operations
137
+ - after_update(context): Called after update operations
138
+ - before_delete(context): Called before delete operations
139
+ - after_delete(context): Called after delete operations
140
+ - before_save(context): Called before save operations (backward compatibility)
141
+ - after_save(context): Called after save operations (backward compatibility)
142
+
143
+ Class-level signals (bulk operations):
144
+ - before_bulk_create(context): Called before bulk create operations
145
+ - after_bulk_create(context): Called after bulk create operations
146
+ - before_bulk_update(context): Called before bulk update operations
147
+ - after_bulk_update(context): Called after bulk update operations
148
+ - before_bulk_delete(context): Called before bulk delete operations
149
+ - after_bulk_delete(context): Called after bulk delete operations
150
+ - before_bulk_save(context): Called before bulk save operations (backward compatibility)
151
+ - after_bulk_save(context): Called after bulk save operations (backward compatibility)
152
+
153
+ Examples:
154
+ >>> class User(ObjectModel, SignalMixin):
155
+ ... name: Column[str] = column(type="string")
156
+ ...
157
+ ... async def before_save(self, context: SignalContext) -> None:
158
+ ... # Called before saving the user
159
+ ... print(f"About to save user: {self.name}")
160
+ ...
161
+ ... async def after_save(self, context: SignalContext) -> None:
162
+ ... # Called after saving the user
163
+ ... print(f"User saved: {self.name}")
164
+ """
165
+
166
+ async def _emit_signal(self, timing: str, context: SignalContext) -> None:
167
+ """Emit an instance-level signal for the specified timing and operation.
168
+
169
+ This method looks for signal handler methods on the instance and calls
170
+ them if they exist. It supports both sync and async handlers.
171
+
172
+ Args:
173
+ timing: Signal timing ("before" or "after")
174
+ context: Signal context containing operation details
175
+
176
+ Examples:
177
+ >>> # This is called internally by the ORM
178
+ >>> await instance._emit_signal("before", context)
179
+ """
180
+ await _emit_signal(self, timing, context)
181
+
182
+ @classmethod
183
+ async def _emit_bulk_signal(cls, timing: str, context: SignalContext) -> None:
184
+ """Emit a bulk signal for the specified timing and operation.
185
+
186
+ This method looks for bulk signal handler methods and calls
187
+ them if they exist. Bulk signals are used for operations that
188
+ affect multiple records without specific instances.
189
+
190
+ Args:
191
+ timing: Signal timing ("before" or "after")
192
+ context: Signal context containing operation details
193
+
194
+ Examples:
195
+ >>> class User(ObjectModel, SignalMixin):
196
+ ... @classmethod
197
+ ... async def before_bulk_update(cls, context: SignalContext) -> None:
198
+ ... print(f"About to update {context.affected_count} users")
199
+ >>> # This is called internally by the ORM
200
+ >>> await User._emit_bulk_signal("before", context)
201
+ """
202
+ await _emit_signal(cls, timing, context)
203
+
204
+
205
+ def emit_signals(operation: Operation, is_bulk: bool = False):
206
+ """Decorator to automatically emit pre/post signals for database operations.
207
+
208
+ Args:
209
+ operation: The database operation type
210
+ is_bulk: Whether this is a bulk operation (affects signal emission strategy)
211
+
212
+ Returns:
213
+ Decorated function that emits signals before and after execution
214
+
215
+ Examples:
216
+ @emit_signals(Operation.SAVE) # Automatically detects CREATE vs UPDATE
217
+ async def save(self, validate: bool = True):
218
+ # Will emit both SAVE and CREATE/UPDATE signals
219
+ pass
220
+
221
+ @emit_signals(Operation.DELETE)
222
+ async def delete(self, **kwargs):
223
+ # Emit DELETE signals
224
+ pass
225
+ """
226
+
227
+ def decorator(func: F) -> F:
228
+ @functools.wraps(func)
229
+ async def wrapper(*args, **kwargs):
230
+ # Extract self/cls and session from arguments
231
+ self_or_cls = args[0]
232
+ session = _extract_session(self_or_cls, kwargs)
233
+
234
+ # Create signal context with original operation
235
+ context = _create_signal_context(
236
+ operation=operation, session=session, self_or_cls=self_or_cls, is_bulk=is_bulk, kwargs=kwargs
237
+ )
238
+
239
+ # For SAVE operations, determine actual CREATE/UPDATE type
240
+ if operation == Operation.SAVE:
241
+ actual_operation = _determine_save_operation(self_or_cls)
242
+ context.actual_operation = actual_operation
243
+ else:
244
+ context.actual_operation = operation
245
+
246
+ # Emit pre signal
247
+ await _emit_pre_signal(self_or_cls, context, is_bulk)
248
+
249
+ try:
250
+ # Execute original method
251
+ result = await func(*args, **kwargs)
252
+
253
+ # Update context with result if needed
254
+ _update_context_with_result(context, result)
255
+
256
+ # Emit post signal
257
+ await _emit_post_signal(self_or_cls, context, is_bulk)
258
+
259
+ return result
260
+
261
+ except Exception:
262
+ raise
263
+
264
+ return wrapper # type: ignore
265
+
266
+ return decorator
267
+
268
+
269
+ def _determine_save_operation(self_or_cls) -> Operation:
270
+ """Determine whether a SAVE operation is CREATE or UPDATE.
271
+
272
+ Args:
273
+ self_or_cls: Model instance or class to check
274
+
275
+ Returns:
276
+ Operation.CREATE if instance has no primary key values, Operation.UPDATE otherwise
277
+ """
278
+ if hasattr(self_or_cls, "__table__"): # Instance method
279
+ # Check if instance has primary key set (indicates UPDATE)
280
+ primary_keys = [col.name for col in self_or_cls.__table__.primary_key.columns]
281
+ if any(getattr(self_or_cls, pk, None) is not None for pk in primary_keys):
282
+ return Operation.UPDATE
283
+ else:
284
+ return Operation.CREATE
285
+ else:
286
+ # For class methods, cannot determine, return CREATE as default
287
+ return Operation.CREATE
288
+
289
+
290
+ def _extract_session(self_or_cls, kwargs) -> Any:
291
+ """Extract session from method arguments or instance.
292
+
293
+ Args:
294
+ self_or_cls: Model instance or class
295
+ kwargs: Method keyword arguments
296
+
297
+ Returns:
298
+ Database session for the operation
299
+ """
300
+ # Try to get session from kwargs first
301
+ if "session" in kwargs:
302
+ return kwargs["session"]
303
+
304
+ # Try to get session from instance/class - support both property and method
305
+ if hasattr(self_or_cls, "_session"):
306
+ session_attr = self_or_cls._session # noqa
307
+ # Handle both property and method cases
308
+ return session_attr() if callable(session_attr) else session_attr
309
+ elif hasattr(self_or_cls, "_get_session"):
310
+ return self_or_cls._get_session() # noqa
311
+
312
+ # Fallback to default session
313
+ from .session import SessionContextManager
314
+
315
+ return SessionContextManager.get_session()
316
+
317
+
318
+ def _create_signal_context(operation: Operation, session, self_or_cls, is_bulk: bool, kwargs: dict) -> SignalContext:
319
+ """Create appropriate signal context based on operation type.
320
+
321
+ Args:
322
+ operation: Database operation type
323
+ session: Database session
324
+ self_or_cls: Model instance or class
325
+ is_bulk: Whether this is a bulk operation
326
+ kwargs: Method keyword arguments
327
+
328
+ Returns:
329
+ SignalContext configured for the operation type
330
+ """
331
+ if is_bulk:
332
+ # Bulk operation context - support ObjectsManager and QuerySet
333
+ if hasattr(self_or_cls, "_model"):
334
+ model_class = self_or_cls._model # noqa
335
+ elif hasattr(self_or_cls, "__table__"):
336
+ model_class = self_or_cls.__class__
337
+ else:
338
+ model_class = self_or_cls
339
+
340
+ return SignalContext(
341
+ operation=operation,
342
+ session=session,
343
+ model_class=model_class,
344
+ instance=None,
345
+ affected_count=kwargs.get("affected_count") or _extract_affected_count(kwargs),
346
+ update_data=kwargs.get("values") or kwargs.get("update_data"),
347
+ )
348
+ else:
349
+ # Single instance operation context
350
+ instance = self_or_cls if hasattr(self_or_cls, "__table__") else None
351
+ model_class = self_or_cls.__class__ if instance else self_or_cls
352
+ return SignalContext(operation=operation, session=session, model_class=model_class, instance=instance)
353
+
354
+
355
+ async def _emit_pre_signal(self_or_cls, context: SignalContext, is_bulk: bool):
356
+ """Emit pre-operation signal.
357
+
358
+ Args:
359
+ self_or_cls: Model instance or class
360
+ context: Signal context
361
+ is_bulk: Whether this is a bulk operation
362
+ """
363
+ if is_bulk:
364
+ # Bulk signal for bulk operations
365
+ model_class = context.model_class
366
+ if hasattr(model_class, "_emit_bulk_signal"):
367
+ await model_class._emit_bulk_signal("before", context) # noqa
368
+ else:
369
+ # Instance-level signal
370
+ if hasattr(self_or_cls, "_emit_signal"):
371
+ await self_or_cls._emit_signal("before", context) # noqa
372
+
373
+
374
+ async def _emit_post_signal(self_or_cls, context: SignalContext, is_bulk: bool):
375
+ """Emit post-operation signal.
376
+
377
+ Args:
378
+ self_or_cls: Model instance or class
379
+ context: Signal context
380
+ is_bulk: Whether this is a bulk operation
381
+ """
382
+ if is_bulk:
383
+ # Bulk signal for bulk operations
384
+ model_class = context.model_class
385
+ if hasattr(model_class, "_emit_bulk_signal"):
386
+ await model_class._emit_bulk_signal("after", context) # noqa
387
+ else:
388
+ # Instance-level signal
389
+ if hasattr(self_or_cls, "_emit_signal"):
390
+ await self_or_cls._emit_signal("after", context) # noqa
391
+
392
+
393
+ def _extract_affected_count(kwargs: dict) -> int | None:
394
+ """Extract affected count from method arguments.
395
+
396
+ Args:
397
+ kwargs: Method keyword arguments
398
+
399
+ Returns:
400
+ Number of records affected by the operation, or None if not determinable
401
+ """
402
+ # For bulk operations, try to extract count from various argument patterns
403
+ if "mappings" in kwargs and isinstance(kwargs["mappings"], list):
404
+ return len(kwargs["mappings"])
405
+ elif "ids" in kwargs and isinstance(kwargs["ids"], list):
406
+ return len(kwargs["ids"])
407
+ elif "objects" in kwargs and isinstance(kwargs["objects"], list):
408
+ return len(kwargs["objects"])
409
+ return None
410
+
411
+
412
+ async def _emit_signal(target, timing: str, context: SignalContext) -> None:
413
+ """Common logic for emitting signal handlers.
414
+
415
+ Args:
416
+ target: Model instance or class containing signal handlers
417
+ timing: Signal timing ("before" or "after")
418
+ context: Signal context containing operation details
419
+ """
420
+ # Determine if this is a bulk operation
421
+ is_bulk = context.is_bulk
422
+ bulk_prefix = "bulk_" if is_bulk else ""
423
+
424
+ if context.operation == Operation.SAVE and context.actual_operation:
425
+ # For SAVE operations, emit both SAVE and actual operation signals
426
+ # Emit SAVE signal first
427
+ save_signal_name = f"{timing}_{bulk_prefix}save"
428
+ save_handler = getattr(target, save_signal_name, None)
429
+ if save_handler and callable(save_handler):
430
+ if inspect.iscoroutinefunction(save_handler):
431
+ await save_handler(context)
432
+ else:
433
+ save_handler(context)
434
+
435
+ # Then emit specific CREATE/UPDATE signal
436
+ specific_signal_name = f"{timing}_{bulk_prefix}{context.actual_operation.value}"
437
+ specific_handler = getattr(target, specific_signal_name, None)
438
+ if specific_handler and callable(specific_handler):
439
+ if inspect.iscoroutinefunction(specific_handler):
440
+ await specific_handler(context)
441
+ else:
442
+ specific_handler(context)
443
+ else:
444
+ # For non-SAVE operations, emit the specific signal
445
+ signal_name = f"{timing}_{bulk_prefix}{context.operation.value}"
446
+ handler = getattr(target, signal_name, None)
447
+
448
+ if handler and callable(handler):
449
+ if inspect.iscoroutinefunction(handler):
450
+ await handler(context)
451
+ else:
452
+ handler(context)
453
+
454
+
455
+ def _update_context_with_result(context: SignalContext, result):
456
+ """Update signal context with method execution result.
457
+
458
+ Args:
459
+ context: Signal context to update
460
+ result: Result from the executed method
461
+ """
462
+ if context.is_bulk and isinstance(result, int):
463
+ # Update affected_count for bulk operations that return row count
464
+ context.affected_count = result
@@ -0,0 +1,5 @@
1
+ from .naming import to_camel_case, to_snake_case
2
+ from .pattern import is_plural, pluralize, singularize
3
+
4
+
5
+ __all__ = ["pluralize", "singularize", "is_plural", "to_snake_case", "to_camel_case"]
@@ -0,0 +1,53 @@
1
+ import re
2
+
3
+
4
+ __all__ = ["to_snake_case", "to_camel_case"]
5
+
6
+
7
+ def to_snake_case(name: str) -> str:
8
+ """Convert CamelCase to snake_case (Rails style).
9
+
10
+ Args:
11
+ name: CamelCase string to convert
12
+
13
+ Returns:
14
+ snake_case string
15
+
16
+ Examples:
17
+ >>> to_snake_case("UserProfile")
18
+ 'user_profile'
19
+ >>> to_snake_case("XMLParser")
20
+ 'xml_parser'
21
+ >>> to_snake_case("HTTPRequest")
22
+ 'http_request'
23
+ """
24
+ # Handle sequences of uppercase letters followed by lowercase
25
+ s1 = re.sub("([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
26
+ # Handle lowercase followed by uppercase
27
+ s2 = re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1)
28
+ return s2.lower()
29
+
30
+
31
+ def to_camel_case(name: str, pascal_case: bool = True) -> str:
32
+ """Convert snake_case to CamelCase.
33
+
34
+ Args:
35
+ name: snake_case string to convert
36
+ pascal_case: If True, return PascalCase; if False, return camelCase
37
+
38
+ Returns:
39
+ CamelCase string
40
+
41
+ Examples:
42
+ >>> to_camel_case("user_profile")
43
+ 'UserProfile'
44
+ >>> to_camel_case("user_profile", pascal_case=False)
45
+ 'userProfile'
46
+ >>> to_camel_case("xml_parser")
47
+ 'XmlParser'
48
+ """
49
+ components = name.split("_")
50
+ if pascal_case:
51
+ return "".join(word.capitalize() for word in components)
52
+ else:
53
+ return components[0] + "".join(word.capitalize() for word in components[1:])