sqlobjects 1.0.3__tar.gz → 1.0.4__tar.gz

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.
Files changed (56) hide show
  1. {sqlobjects-1.0.3/sqlobjects.egg-info → sqlobjects-1.0.4}/PKG-INFO +1 -1
  2. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/pyproject.toml +1 -1
  3. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/mixins.py +49 -1
  4. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/model.py +71 -70
  5. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/objects/bulk.py +10 -13
  6. {sqlobjects-1.0.3 → sqlobjects-1.0.4/sqlobjects.egg-info}/PKG-INFO +1 -1
  7. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/LICENSE +0 -0
  8. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/README.md +0 -0
  9. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/setup.cfg +0 -0
  10. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/__init__.py +0 -0
  11. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/cascade.py +0 -0
  12. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/database/__init__.py +0 -0
  13. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/database/config.py +0 -0
  14. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/database/manager.py +0 -0
  15. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/exceptions.py +0 -0
  16. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/expressions/__init__.py +0 -0
  17. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/expressions/aggregate.py +0 -0
  18. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/expressions/base.py +0 -0
  19. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/expressions/function.py +0 -0
  20. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/expressions/mixins.py +0 -0
  21. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/expressions/scalar.py +0 -0
  22. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/expressions/subquery.py +0 -0
  23. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/expressions/terminal.py +0 -0
  24. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/fields/__init__.py +0 -0
  25. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/fields/core.py +0 -0
  26. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/fields/functions.py +0 -0
  27. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/fields/proxies.py +0 -0
  28. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/fields/relations/__init__.py +0 -0
  29. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/fields/relations/descriptors.py +0 -0
  30. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/fields/relations/managers.py +0 -0
  31. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/fields/relations/proxies.py +0 -0
  32. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/fields/relations/utils.py +0 -0
  33. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/fields/shortcuts.py +0 -0
  34. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/fields/types/__init__.py +0 -0
  35. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/fields/types/base.py +0 -0
  36. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/fields/types/comparators.py +0 -0
  37. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/fields/types/registry.py +0 -0
  38. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/fields/utils.py +0 -0
  39. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/metadata.py +0 -0
  40. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/objects/__init__.py +0 -0
  41. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/objects/core.py +0 -0
  42. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/queries/__init__.py +0 -0
  43. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/queries/builder.py +0 -0
  44. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/queries/executor.py +0 -0
  45. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/queryset.py +0 -0
  46. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/session.py +0 -0
  47. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/signals.py +0 -0
  48. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/utils/__init__.py +0 -0
  49. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/utils/inspect.py +0 -0
  50. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/utils/naming.py +0 -0
  51. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/utils/pattern.py +0 -0
  52. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects/validators.py +0 -0
  53. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects.egg-info/SOURCES.txt +0 -0
  54. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects.egg-info/dependency_links.txt +0 -0
  55. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects.egg-info/requires.txt +0 -0
  56. {sqlobjects-1.0.3 → sqlobjects-1.0.4}/sqlobjects.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlobjects
3
- Version: 1.0.3
3
+ Version: 1.0.4
4
4
  Summary: Django-style async ORM library based on SQLAlchemy with chainable queries, Q objects, and relationship loading
5
5
  Author-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
6
6
  Maintainer-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlobjects"
3
- version = "1.0.3"
3
+ version = "1.0.4"
4
4
  description = "Django-style async ORM library based on SQLAlchemy with chainable queries, Q objects, and relationship loading"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -417,7 +417,7 @@ class DataConversionMixin(DeferredLoadingMixin):
417
417
  for field_name, value in non_init_data.items():
418
418
  # Apply default value if value is None
419
419
  if value is None:
420
- default_value = instance._get_field_default_value(field_name) # noqa # type: ignore[reportAttributeAccessIssue]
420
+ default_value = instance._get_field_default_value(field_name) # noqa
421
421
  if default_value is not None:
422
422
  value = default_value
423
423
  setattr(instance, field_name, value)
@@ -432,6 +432,46 @@ class DataConversionMixin(DeferredLoadingMixin):
432
432
 
433
433
  return instance
434
434
 
435
+ def _apply_default_values(self, kwargs: dict):
436
+ """Apply default values for fields not provided in kwargs.
437
+
438
+ Args:
439
+ kwargs: Dictionary of provided field values (will be modified)
440
+ """
441
+ for field_name in self._get_field_names():
442
+ if field_name not in kwargs or kwargs[field_name] is None:
443
+ default_value = self._get_field_default_value(field_name)
444
+ if default_value is not None:
445
+ kwargs[field_name] = default_value
446
+
447
+ def _get_field_default_value(self, field_name: str):
448
+ """Get default value for a field.
449
+
450
+ Args:
451
+ field_name: Name of the field
452
+
453
+ Returns:
454
+ Default value or None if no default
455
+ """
456
+ field_attr = getattr(self.__class__, field_name, None)
457
+ if field_attr is None:
458
+ return None
459
+
460
+ # Priority: default_factory > SQLAlchemy default
461
+ if hasattr(field_attr, "get_default_factory"):
462
+ factory = field_attr.get_default_factory()
463
+ if factory and callable(factory):
464
+ return factory()
465
+
466
+ if hasattr(field_attr, "default") and field_attr.default is not None:
467
+ default_value = field_attr.default
468
+ if callable(default_value):
469
+ return default_value()
470
+ else:
471
+ return default_value
472
+
473
+ return None
474
+
435
475
 
436
476
  class FieldCacheMixin(DataConversionMixin):
437
477
  """Field caching and attribute access optimization - Layer 6."""
@@ -592,6 +632,13 @@ class FieldCacheMixin(DataConversionMixin):
592
632
 
593
633
  relationship_fields = field_cache.get("relationship_fields", set())
594
634
  if isinstance(relationship_fields, set) and name in relationship_fields:
635
+ # Check cascade_relationships first (manually assigned values)
636
+ if hasattr(self, "_state_manager"):
637
+ cascade_relationships: dict = self._state_manager.get("cascade_relationships", {}) # type: ignore[reportAssignmentType]
638
+ if name in cascade_relationships:
639
+ return cascade_relationships[name]
640
+
641
+ # Check preloaded cache
595
642
  cache_name = f"_{name}_cache"
596
643
  try:
597
644
  if hasattr(self, cache_name):
@@ -601,6 +648,7 @@ class FieldCacheMixin(DataConversionMixin):
601
648
  except AttributeError:
602
649
  pass
603
650
 
651
+ # Only create proxy if relationship is not loaded
604
652
  proxy_cache = self._state_manager.get("proxy_cache", {})
605
653
  if isinstance(proxy_cache, dict) and name not in proxy_cache:
606
654
  proxy_cache[name] = RelationFieldProxy(self, name)
@@ -110,8 +110,36 @@ class ModelMixin(FieldCacheMixin, SignalMixin):
110
110
  if i < len(pk_values):
111
111
  setattr(self, col.name, pk_values[i])
112
112
 
113
+ def _get_upsert_statement(self, table, data):
114
+ """Construct UPSERT statement based on database dialect."""
115
+ dialect = self.get_session().bind.dialect.name
116
+
117
+ pk_columns = list(table.primary_key.columns)
118
+
119
+ if dialect == "postgresql":
120
+ from sqlalchemy.dialects.postgresql import insert
121
+
122
+ stmt = insert(table).values(**data)
123
+ return stmt.on_conflict_do_update(index_elements=pk_columns, set_=data)
124
+
125
+ elif dialect == "mysql":
126
+ from sqlalchemy.dialects.mysql import insert
127
+
128
+ stmt = insert(table).values(**data)
129
+ return stmt.on_duplicate_key_update(**data)
130
+
131
+ elif dialect == "sqlite":
132
+ from sqlalchemy.dialects.sqlite import insert
133
+
134
+ stmt = insert(table).values(**data)
135
+ return stmt.on_conflict_do_update(index_elements=pk_columns, set_=data)
136
+
137
+ else:
138
+ # Return None for unsupported dialects to trigger fallback
139
+ return None
140
+
113
141
  async def _save_internal(self, validate: bool = True, session=None):
114
- """Internal save operation without cascade or signal emission.
142
+ """Internal save operation using UPSERT with fallback to query-then-save.
115
143
 
116
144
  This method contains the core save logic that can be reused by both
117
145
  the public save() method and the cascade executor without triggering
@@ -135,17 +163,37 @@ class ModelMixin(FieldCacheMixin, SignalMixin):
135
163
  if validate:
136
164
  self.validate_all_fields()
137
165
 
166
+ data = self._get_all_data()
167
+
168
+ # Try UPSERT for supported databases
169
+ upsert_stmt = self._get_upsert_statement(table, data)
170
+ if upsert_stmt is not None:
171
+ try:
172
+ result = await session.execute(upsert_stmt)
173
+ if result.inserted_primary_key:
174
+ self._set_primary_key_values(result.inserted_primary_key)
175
+ # Clear dirty fields after successful save
176
+ dirty_fields = self._state_manager.get("dirty_fields", set())
177
+ if isinstance(dirty_fields, set):
178
+ dirty_fields.clear()
179
+ return self
180
+ except Exception as e:
181
+ raise PrimaryKeyError(f"Upsert operation failed: {e}") from e
182
+
183
+ # Fallback: query database to determine INSERT or UPDATE
138
184
  try:
139
- if self._has_primary_key_values():
140
- # UPDATE operation
141
- pk_conditions = self._build_pk_conditions()
185
+ pk_conditions = self._build_pk_conditions()
186
+ existing = await session.execute(select(table).where(and_(*pk_conditions)))
187
+
188
+ if existing.first():
189
+ # Record exists, perform UPDATE
142
190
  update_data = self._get_dirty_data()
143
191
  if update_data:
144
192
  stmt = update(table).where(and_(*pk_conditions)).values(**update_data)
145
193
  await session.execute(stmt)
146
194
  else:
147
- # INSERT operation
148
- stmt = insert(table).values(**self._get_all_data())
195
+ # Record does not exist, perform INSERT
196
+ stmt = insert(table).values(**data)
149
197
  result = await session.execute(stmt)
150
198
  if result.inserted_primary_key:
151
199
  self._set_primary_key_values(result.inserted_primary_key)
@@ -219,8 +267,8 @@ class ModelMixin(FieldCacheMixin, SignalMixin):
219
267
  for rel_name, new_related_objects in cascade_relationships.items():
220
268
  await self._process_relationship_update(rel_name, new_related_objects, session)
221
269
 
222
- # Clear cascade state
223
- self._state_manager.set("cascade_relationships", {})
270
+ # keep cascade_relationships
271
+ # self._state_manager.set("cascade_relationships", {})
224
272
  self._state_manager.set("needs_cascade_save", False)
225
273
 
226
274
  async def _process_relationship_update(self, rel_name: str, new_related_objects, session):
@@ -261,32 +309,30 @@ class ModelMixin(FieldCacheMixin, SignalMixin):
261
309
 
262
310
  async def _fetch_current_related_objects(self, rel_name: str, session) -> list:
263
311
  """Fetch current related objects from database."""
264
- # Simple implementation for common patterns
265
- relationship_mappings = {
266
- "posts": ("CascadePost", "author_id"),
267
- "profile": ("CascadeProfile", "user_id"),
268
- }
312
+ relationships = getattr(self.__class__, "_relationships", {})
313
+ if rel_name not in relationships:
314
+ return []
269
315
 
270
- if rel_name not in relationship_mappings:
316
+ rel_descriptor = relationships[rel_name]
317
+ if not hasattr(rel_descriptor.property, "resolved_model") or not rel_descriptor.property.resolved_model:
271
318
  return []
272
319
 
273
- related_model_name, fk_field = relationship_mappings[rel_name]
320
+ related_model = rel_descriptor.property.resolved_model
321
+ foreign_keys = rel_descriptor.property.foreign_keys
274
322
 
275
- # Import model class
276
- if related_model_name == "CascadePost":
277
- from tests.integration.test_cascade_integration import CascadePost as RelatedModel
278
- elif related_model_name == "CascadeProfile":
279
- from tests.integration.test_cascade_integration import CascadeProfile as RelatedModel
280
- else:
323
+ if not foreign_keys:
281
324
  return []
282
325
 
283
- # Query current objects
326
+ # fetch foreign keys
327
+ fk_field = foreign_keys if isinstance(foreign_keys, str) else foreign_keys[0]
328
+
329
+ # get pk
284
330
  pk_value = getattr(self, self._get_primary_key_field())
285
331
  if pk_value is None:
286
332
  return []
287
333
 
288
334
  current_objects = (
289
- await RelatedModel.objects.using(session).filter(getattr(RelatedModel, fk_field) == pk_value).all()
335
+ await related_model.objects.using(session).filter(getattr(related_model, fk_field) == pk_value).all()
290
336
  )
291
337
 
292
338
  return current_objects
@@ -431,23 +477,18 @@ class ModelMixin(FieldCacheMixin, SignalMixin):
431
477
  from .cascade import OnDelete
432
478
 
433
479
  relationships = getattr(self.__class__, "_relationships", {})
434
- print(f"DEBUG: _has_on_delete_relations checking {len(relationships)} relationships")
435
- for rel_name, rel_descriptor in relationships.items():
436
- print(f"DEBUG: Checking relationship {rel_name}")
480
+
481
+ for _, rel_descriptor in relationships.items():
437
482
  if hasattr(rel_descriptor, "property") and hasattr(rel_descriptor.property, "cascade"):
438
483
  cascade_str = rel_descriptor.property.cascade
439
- print(f"DEBUG: Cascade string: {cascade_str}")
440
484
  if cascade_str and ("delete" in cascade_str or "all" in cascade_str):
441
- print(f"DEBUG: Found delete cascade relationship: {rel_name}")
442
485
  return True
443
486
  if (
444
487
  hasattr(rel_descriptor, "property")
445
488
  and hasattr(rel_descriptor.property, "on_delete")
446
489
  and rel_descriptor.property.on_delete != OnDelete.NO_ACTION
447
490
  ):
448
- print(f"DEBUG: Found on_delete relationship: {rel_name}")
449
491
  return True
450
- print("DEBUG: No on_delete relations found")
451
492
  return False
452
493
 
453
494
  def _get_primary_key_field(self) -> str:
@@ -503,46 +544,6 @@ class ModelMixin(FieldCacheMixin, SignalMixin):
503
544
  relationships = getattr(self.__class__, "_relationships", {})
504
545
  return set(relationships.keys())
505
546
 
506
- def _apply_default_values(self, kwargs: dict):
507
- """Apply default values for fields not provided in kwargs.
508
-
509
- Args:
510
- kwargs: Dictionary of provided field values (will be modified)
511
- """
512
- for field_name in self._get_field_names():
513
- if field_name not in kwargs or kwargs[field_name] is None:
514
- default_value = self._get_field_default_value(field_name)
515
- if default_value is not None:
516
- kwargs[field_name] = default_value
517
-
518
- def _get_field_default_value(self, field_name: str):
519
- """Get default value for a field.
520
-
521
- Args:
522
- field_name: Name of the field
523
-
524
- Returns:
525
- Default value or None if no default
526
- """
527
- field_attr = getattr(self.__class__, field_name, None)
528
- if field_attr is None:
529
- return None
530
-
531
- # Priority: default_factory > SQLAlchemy default
532
- if hasattr(field_attr, "get_default_factory"):
533
- factory = field_attr.get_default_factory()
534
- if factory and callable(factory):
535
- return factory()
536
-
537
- if hasattr(field_attr, "default") and field_attr.default is not None:
538
- default_value = field_attr.default
539
- if callable(default_value):
540
- return default_value()
541
- else:
542
- return default_value
543
-
544
- return None
545
-
546
547
 
547
548
  class ObjectModel(ModelMixin, metaclass=ModelProcessor):
548
549
  """Base model class with configuration support and common functionality.
@@ -323,19 +323,16 @@ class BulkOperationHandler:
323
323
  exec_session = session or self.session
324
324
 
325
325
  if return_columns and self.supports_returning(operation):
326
- try:
327
- stmt_with_returning = stmt.returning(*return_columns)
328
- # For INSERT operations, use the data directly as parameters
329
- if operation == "insert" and isinstance(parameters, list):
330
- result = await exec_session.execute(stmt_with_returning, parameters)
331
- elif parameters:
332
- result = await exec_session.execute(stmt_with_returning, parameters)
333
- else:
334
- result = await exec_session.execute(stmt_with_returning)
335
- objects = self.create_objects_from_rows(result.fetchall(), return_fields)
336
- return objects, result.rowcount or 0, True
337
- except Exception: # noqa
338
- pass # Fall through to regular execution
326
+ stmt_with_returning = stmt.returning(*return_columns)
327
+ # For INSERT operations, use the data directly as parameters
328
+ if operation == "insert" and isinstance(parameters, list):
329
+ result = await exec_session.execute(stmt_with_returning, parameters)
330
+ elif parameters:
331
+ result = await exec_session.execute(stmt_with_returning, parameters)
332
+ else:
333
+ result = await exec_session.execute(stmt_with_returning)
334
+ objects = self.create_objects_from_rows(result.fetchall(), return_fields)
335
+ return objects, result.rowcount or 0, True
339
336
 
340
337
  # Regular execution without RETURNING
341
338
  if parameters is not None and isinstance(parameters, list) and len(parameters) > 1:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlobjects
3
- Version: 1.0.3
3
+ Version: 1.0.4
4
4
  Summary: Django-style async ORM library based on SQLAlchemy with chainable queries, Q objects, and relationship loading
5
5
  Author-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
6
6
  Maintainer-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
File without changes
File without changes
File without changes