sqlobjects 1.2.1__tar.gz → 1.2.3__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 (77) hide show
  1. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/CHANGELOG.md +19 -0
  2. {sqlobjects-1.2.1/sqlobjects.egg-info → sqlobjects-1.2.3}/PKG-INFO +1 -1
  3. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/pyproject.toml +1 -1
  4. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/database/manager.py +22 -12
  5. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/exceptions.py +13 -0
  6. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/expressions/function.py +10 -10
  7. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/fields/core.py +1 -0
  8. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/metadata.py +87 -214
  9. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/queries/executor.py +15 -2
  10. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/queryset.py +9 -6
  11. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/session.py +9 -0
  12. {sqlobjects-1.2.1 → sqlobjects-1.2.3/sqlobjects.egg-info}/PKG-INFO +1 -1
  13. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/tests/test_config.py +2 -2
  14. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/LICENSE +0 -0
  15. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/README.md +0 -0
  16. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/docs/rules/01-database-session-guide.md +0 -0
  17. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/docs/rules/02-model-definition-guide.md +0 -0
  18. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/docs/rules/03-query-operations-guide.md +0 -0
  19. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/docs/rules/04-crud-operations-guide.md +0 -0
  20. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/docs/rules/05-relationships-guide.md +0 -0
  21. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/docs/rules/06-validation-signals-guide.md +0 -0
  22. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/docs/rules/07-performance-guide.md +0 -0
  23. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/docs/rules/README.md +0 -0
  24. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/setup.cfg +0 -0
  25. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/__init__.py +0 -0
  26. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/_install_rules.py +0 -0
  27. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/cascade.py +0 -0
  28. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/database/__init__.py +0 -0
  29. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/database/config.py +0 -0
  30. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/expressions/__init__.py +0 -0
  31. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/expressions/aggregate.py +0 -0
  32. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/expressions/base.py +0 -0
  33. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/expressions/cte.py +0 -0
  34. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/expressions/explain.py +0 -0
  35. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/expressions/mixins.py +0 -0
  36. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/expressions/scalar.py +0 -0
  37. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/expressions/subquery.py +0 -0
  38. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/expressions/terminal.py +0 -0
  39. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/expressions/window.py +0 -0
  40. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/fields/__init__.py +0 -0
  41. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/fields/functions.py +0 -0
  42. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/fields/proxies.py +0 -0
  43. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/fields/relations/__init__.py +0 -0
  44. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/fields/relations/descriptors.py +0 -0
  45. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/fields/relations/managers.py +0 -0
  46. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/fields/relations/prefetch.py +0 -0
  47. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/fields/relations/strategies.py +0 -0
  48. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/fields/relations/utils.py +0 -0
  49. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/fields/shortcuts.py +0 -0
  50. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/fields/types/__init__.py +0 -0
  51. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/fields/types/base.py +0 -0
  52. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/fields/types/comparators.py +0 -0
  53. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/fields/types/registry.py +0 -0
  54. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/fields/utils.py +0 -0
  55. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/internal/__init__.py +0 -0
  56. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/internal/operations.py +0 -0
  57. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/internal/results.py +0 -0
  58. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/mixins.py +0 -0
  59. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/model.py +0 -0
  60. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/objects/__init__.py +0 -0
  61. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/objects/bulk.py +0 -0
  62. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/objects/core.py +0 -0
  63. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/objects/upsert.py +0 -0
  64. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/queries/__init__.py +0 -0
  65. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/queries/builder.py +0 -0
  66. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/queries/dialect.py +0 -0
  67. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/signals.py +0 -0
  68. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/utils/__init__.py +0 -0
  69. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/utils/inspect.py +0 -0
  70. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/utils/naming.py +0 -0
  71. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/utils/pattern.py +0 -0
  72. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects/validators.py +0 -0
  73. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects.egg-info/SOURCES.txt +0 -0
  74. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects.egg-info/dependency_links.txt +0 -0
  75. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects.egg-info/entry_points.txt +0 -0
  76. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects.egg-info/requires.txt +0 -0
  77. {sqlobjects-1.2.1 → sqlobjects-1.2.3}/sqlobjects.egg-info/top_level.txt +0 -0
@@ -1,3 +1,22 @@
1
+ ## 1.2.3 (2026-02-26)
2
+
3
+ ### Fix
4
+
5
+ - resolve PostgreSQL test failures and cross-test data pollution
6
+ - **executor**: add overloads to execute() and fix iterator type narrowing
7
+ - **queryset**: replace non-existent executor.session with _get_session()
8
+ - enhance exception handling to surface detailed SQLAlchemy errors
9
+
10
+ ### Refactor
11
+
12
+ - **metadata**: simplify index handling and config parsing
13
+
14
+ ## 1.2.2 (2026-02-26)
15
+
16
+ ### Fix
17
+
18
+ - clone Column descriptor per subclass to prevent shared ColumnAttribute binding
19
+
1
20
  ## 1.2.1 (2026-02-25)
2
21
 
3
22
  ### Refactor
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlobjects
3
- Version: 1.2.1
3
+ Version: 1.2.3
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.2.1"
3
+ version = "1.2.3"
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 = "MIT"
@@ -189,12 +189,17 @@ class Database:
189
189
  >>> await db.create_tables(ObjectModel) # Create all tables
190
190
  >>> await db.create_tables(ObjectModel, [User, Post]) # Create specific tables
191
191
  """
192
- async with self.engine.begin() as conn:
193
- if tables is None:
194
- await conn.run_sync(base_class.__registry__.create_all)
195
- else:
196
- table_objects = [model.__table__ for model in tables]
197
- await conn.run_sync(base_class.__registry__.create_all, tables=table_objects)
192
+ try:
193
+ async with self.engine.begin() as conn:
194
+ if tables is None:
195
+ await conn.run_sync(base_class.__registry__.create_all)
196
+ else:
197
+ table_objects = [model.__table__ for model in tables]
198
+ await conn.run_sync(base_class.__registry__.create_all, tables=table_objects)
199
+ except Exception as e:
200
+ from ..exceptions import convert_sqlalchemy_error
201
+
202
+ raise convert_sqlalchemy_error(e) from e
198
203
 
199
204
  async def drop_tables(self, base_class, tables: list[type] | None = None) -> None:
200
205
  """Drop tables defined in the model registry of SQLObjects base class
@@ -211,12 +216,17 @@ class Database:
211
216
  >>> await db.drop_tables(ObjectModel) # Drop all tables
212
217
  >>> await db.drop_tables(ObjectModel, [User, Post]) # Drop specific tables
213
218
  """
214
- async with self.engine.begin() as conn:
215
- if tables is None:
216
- await conn.run_sync(base_class.__registry__.drop_all)
217
- else:
218
- table_objects = [model.__table__ for model in tables]
219
- await conn.run_sync(base_class.__registry__.drop_all, tables=table_objects)
219
+ try:
220
+ async with self.engine.begin() as conn:
221
+ if tables is None:
222
+ await conn.run_sync(base_class.__registry__.drop_all)
223
+ else:
224
+ table_objects = [model.__table__ for model in tables]
225
+ await conn.run_sync(base_class.__registry__.drop_all, tables=table_objects)
226
+ except Exception as e:
227
+ from ..exceptions import convert_sqlalchemy_error
228
+
229
+ raise convert_sqlalchemy_error(e) from e
220
230
 
221
231
  async def disconnect(self) -> None:
222
232
  """Disconnect database and clean up resources
@@ -458,8 +458,21 @@ def convert_sqlalchemy_error(error: Exception) -> SQLObjectsError:
458
458
  ... except SQLAlchemyError as e:
459
459
  ... raise convert_sqlalchemy_error(e)
460
460
  """
461
+ # Build detailed error message with context
461
462
  error_msg = str(error)
462
463
 
464
+ # Add SQL statement if available
465
+ if hasattr(error, "statement") and getattr(error, "statement", None):
466
+ error_msg += f"\nSQL: {error.statement}" # type: ignore[reportAttributeAccessIssue]
467
+
468
+ # Add parameters if available
469
+ if hasattr(error, "params") and getattr(error, "params", None):
470
+ error_msg += f"\nParams: {error.params}" # type: ignore[reportAttributeAccessIssue]
471
+
472
+ # Add original database error if available
473
+ if hasattr(error, "orig") and getattr(error, "orig", None):
474
+ error_msg += f"\nOriginal: {error.orig}" # type: ignore[reportAttributeAccessIssue]
475
+
463
476
  if isinstance(error, SQLAIntegrityError):
464
477
  return IntegrityError(error_msg, original_error=error)
465
478
  elif isinstance(error, SQLAOperationalError):
@@ -432,13 +432,13 @@ class _FuncWrapper:
432
432
  func = _FuncWrapper()
433
433
 
434
434
  # Add window functions at runtime
435
- func.row_number = lambda: RowNumberFunction() # type: ignore
436
- func.rank = lambda: RankFunction() # type: ignore
437
- func.dense_rank = lambda: DenseRankFunction() # type: ignore
438
- func.percent_rank = lambda: PercentRankFunction() # type: ignore
439
- func.ntile = lambda n: NtileFunction(n) # type: ignore
440
- func.lag = lambda col, offset=1, default=None: LagFunction(col, offset, default) # type: ignore
441
- func.lead = lambda col, offset=1, default=None: LeadFunction(col, offset, default) # type: ignore
442
- func.first_value = lambda col: FirstValueFunction(col) # type: ignore
443
- func.last_value = lambda col: LastValueFunction(col) # type: ignore
444
- func.nth_value = lambda col, n: NthValueFunction(col, n) # type: ignore
435
+ func.row_number = lambda: RowNumberFunction()
436
+ func.rank = lambda: RankFunction()
437
+ func.dense_rank = lambda: DenseRankFunction()
438
+ func.percent_rank = lambda: PercentRankFunction()
439
+ func.ntile = lambda n: NtileFunction(n)
440
+ func.lag = lambda col, offset=1, default=None: LagFunction(col, offset, default)
441
+ func.lead = lambda col, offset=1, default=None: LeadFunction(col, offset, default)
442
+ func.first_value = lambda col: FirstValueFunction(col)
443
+ func.last_value = lambda col: LastValueFunction(col)
444
+ func.nth_value = lambda col, n: NthValueFunction(col, n)
@@ -317,6 +317,7 @@ class ColumnAttribute(ColumnAttributeFunctionMixin, Generic[T]):
317
317
  """
318
318
 
319
319
  inherit_cache = True # make use of the cache key generated by the superclass from SQLAlchemy
320
+ is_clause_element = False # force SQLAlchemy to call __clause_element__() for proper coercion
320
321
 
321
322
  def __getattr__(self, name):
322
323
  """Handle attribute access with proper priority.
@@ -45,25 +45,7 @@ class _RawModelConfig:
45
45
 
46
46
  @dataclass
47
47
  class ModelConfig:
48
- """Complete model configuration with all required fields filled.
49
-
50
- This dataclass holds all configuration options that can be applied to a model,
51
- including basic settings, database constraints, metadata, and database-specific
52
- optimizations. All required fields are guaranteed to have values.
53
-
54
- Attributes:
55
- table_name: Database table name (never None after processing)
56
- verbose_name: Human-readable singular name for the model (never None)
57
- verbose_name_plural: Human-readable plural name for the model (never None)
58
- ordering: Default ordering for queries (e.g., ['-created_at', 'name'])
59
- indexes: List of database indexes to create for the table
60
- constraints: List of database constraints (check, unique) for the table
61
- description: Detailed description of the model's purpose (can be None)
62
- db_options: Database-specific configuration options by dialect
63
- custom: Custom configuration values for application-specific use
64
- field_validators: Field-level validators registry
65
- field_metadata: Unified field metadata information
66
- """
48
+ """Complete model configuration with all required fields filled."""
67
49
 
68
50
  table_name: str
69
51
  verbose_name: str
@@ -425,25 +407,10 @@ class ModelProcessor(type):
425
407
 
426
408
  @classmethod
427
409
  def _integrate_field_config(mcs, cls: Any, config: ModelConfig) -> ModelConfig:
428
- """Integrate field-level configuration into model configuration - optimized version.
429
-
430
- Args:
431
- cls: Model class
432
- config: Current model configuration
433
-
434
- Returns:
435
- Updated model configuration with field-level settings integrated
436
- """
437
- # Collect all field configuration in single pass
438
410
  field_indexes, field_validators, field_metadata = mcs._collect_all_field_config(cls, config.table_name)
439
-
440
- # Merge indexes (avoid duplicates)
441
- config.indexes = mcs._merge_indexes(field_indexes, config.indexes, config.table_name)
442
-
443
- # Set validators and metadata
411
+ config.indexes = field_indexes + config.indexes
444
412
  config.field_validators = field_validators
445
413
  config.field_metadata = field_metadata
446
-
447
414
  return config
448
415
 
449
416
  @classmethod
@@ -533,82 +500,6 @@ class ModelProcessor(type):
533
500
 
534
501
  return indexes, validators, metadata
535
502
 
536
- @classmethod
537
- def _merge_indexes(mcs, field_indexes: list[Index], table_indexes: list[Index], table_name: str) -> list[Index]:
538
- """Merge field-level and table-level indexes, avoiding duplicates.
539
-
540
- Args:
541
- field_indexes: Indexes generated from field definitions
542
- table_indexes: Indexes defined at table level
543
- table_name: Database table name
544
-
545
- Returns:
546
- Merged list of unique indexes
547
- """
548
-
549
- def get_index_signature(idx): # noqa
550
- if hasattr(idx, "_columns") and idx._columns: # noqa
551
- columns = tuple(sorted(str(col) for col in idx._columns)) # noqa
552
- return (columns, idx.unique) # noqa
553
- return None
554
-
555
- # Collect table-level index signatures
556
- table_signatures = set()
557
- for idx in table_indexes:
558
- sig = get_index_signature(idx)
559
- if sig:
560
- table_signatures.add(sig)
561
-
562
- # Filter duplicate field-level indexes
563
- merged_indexes = []
564
- for idx in field_indexes:
565
- sig = get_index_signature(idx)
566
- if sig and sig not in table_signatures:
567
- merged_indexes.append(idx)
568
-
569
- # Add table-level indexes
570
- merged_indexes.extend(table_indexes)
571
-
572
- # Normalize all index naming format
573
- return mcs._normalize_all_indexes(merged_indexes, table_name)
574
-
575
- @classmethod
576
- def _normalize_all_indexes(mcs, indexes: list[Index], table_name: str) -> list[Index]:
577
- """Normalize index names that need normalization.
578
-
579
- Only normalizes indexes with temporary names (starting with __temp__idx_).
580
-
581
- Args:
582
- indexes: List of indexes to normalize
583
- table_name: Database table name
584
-
585
- Returns:
586
- List of indexes with normalized names
587
- """
588
- normalized_indexes = []
589
-
590
- for idx in indexes:
591
- # Only normalize temporary names
592
- if not idx.name or not idx.name.startswith(_TEMP_INDEX_PREFIX):
593
- normalized_indexes.append(idx)
594
- continue
595
-
596
- # Get field name list
597
- if hasattr(idx, "columns") and idx.columns:
598
- field_names = "_".join(col.name for col in idx.columns) # noqa
599
- elif hasattr(idx, "_columns") and idx._columns: # noqa
600
- # Handle indexes not yet bound to table
601
- field_names = "_".join(str(col).split(".")[-1] for col in idx._columns) # noqa
602
- else:
603
- normalized_indexes.append(idx)
604
- continue
605
-
606
- # Generate standardized name
607
- idx.name = f"idx_{table_name}_{field_names}" # type: ignore[reportAttributeAccessIssue]
608
- normalized_indexes.append(idx)
609
-
610
- return normalized_indexes
611
-
612
503
  @classmethod
613
504
  def _register_field_validators(mcs, cls: Any, config: ModelConfig) -> None:
614
505
  """Register field-level validators to model class.
@@ -634,6 +525,14 @@ class ModelProcessor(type):
634
525
  """
635
526
  from sqlalchemy import Table
636
527
 
528
+ # Collect fields with explicit indexes to avoid duplicates
529
+ indexed_fields = set()
530
+ for idx in config.indexes:
531
+ if hasattr(idx, "_columns"):
532
+ for col in idx._columns:
533
+ if isinstance(col, str):
534
+ indexed_fields.add(col.split(".")[-1])
535
+
637
536
  # Collect column definitions and relationship fields
638
537
  columns = []
639
538
  relationships = {}
@@ -651,6 +550,10 @@ class ModelProcessor(type):
651
550
  # Use create_table_column method to get independent Column instance
652
551
  if hasattr(column_attr, "create_table_column"):
653
552
  column = column_attr.create_table_column(name)
553
+ # Clear index attributes if field has explicit index
554
+ if name in indexed_fields:
555
+ column.index = False
556
+ column.unique = False
654
557
  else:
655
558
  # Fallback for non-ColumnAttribute fields
656
559
  column = column_attr
@@ -685,22 +588,40 @@ class ModelProcessor(type):
685
588
 
686
589
  @classmethod
687
590
  def _post_process_table_indexes(mcs, table, table_name: str) -> None:
688
- """Normalize index names after table construction.
591
+ def col_sig(idx):
592
+ return tuple(sorted(col.name for col in idx.columns)) if idx.columns else None
689
593
 
690
- Only normalizes indexes with temporary names (starting with __temp__idx_).
594
+ full_sig_map: dict[tuple, list] = {} # (cols, unique) -> indexes
595
+ col_map: dict[tuple, list] = {} # cols -> indexes
691
596
 
692
- Args:
693
- table: SQLAlchemy Table instance
694
- table_name: Database table name
695
- """
696
- for idx in table.indexes:
697
- # Only normalize temporary names
698
- if not idx.name or not idx.name.startswith(_TEMP_INDEX_PREFIX):
597
+ for idx in list(table.indexes):
598
+ cs = col_sig(idx)
599
+ if cs is None:
699
600
  continue
601
+ full_sig_map.setdefault((cs, getattr(idx, "unique", False)), []).append(idx)
602
+ col_map.setdefault(cs, []).append(idx)
603
+
604
+ to_remove: set = set()
700
605
 
701
- if hasattr(idx, "columns") and idx.columns:
702
- field_names = "_".join(col.name for col in idx.columns)
703
- idx.name = f"idx_{table_name}_{field_names}"
606
+ # Remove exact duplicates: prefer explicit names over SQLAlchemy auto-generated "ix_*"
607
+ for indexes in full_sig_map.values():
608
+ if len(indexes) > 1:
609
+ explicit = [i for i in indexes if not (i.name and i.name.startswith("ix_"))]
610
+ keep = explicit[0] if explicit else indexes[0]
611
+ to_remove.update(i for i in indexes if i is not keep)
612
+
613
+ # Remove non-unique when unique exists on same columns
614
+ for indexes in col_map.values():
615
+ if any(getattr(i, "unique", False) for i in indexes):
616
+ to_remove.update(i for i in indexes if not getattr(i, "unique", False))
617
+
618
+ for idx in to_remove:
619
+ table.indexes.discard(idx)
620
+
621
+ # Normalize temp names after dedup
622
+ for idx in table.indexes:
623
+ if idx.name and idx.name.startswith(_TEMP_INDEX_PREFIX) and idx.columns:
624
+ idx.name = f"idx_{table_name}_{'_'.join(col.name for col in idx.columns)}"
704
625
 
705
626
  @classmethod
706
627
  def _post_process_table_constraints(mcs, table, table_name: str) -> None:
@@ -938,13 +859,25 @@ class ModelProcessor(type):
938
859
  cls: Model class
939
860
  table: SQLAlchemy Table instance
940
861
  """
941
- for name, field_def in mcs._get_fields(cls).items():
942
- if is_field_definition(field_def) and not getattr(field_def, "_is_relationship", False):
943
- column_attr = get_column_from_field(field_def)
944
- if column_attr is not None and hasattr(column_attr, "__column__"):
945
- # Update the ColumnAttribute's internal column to reference the table column
946
- if name in table.columns:
947
- column_attr.__column__ = table.columns[name] # type: ignore[reportAttributeAccessIssue]
862
+ for name in table.columns.keys():
863
+ for klass in cls.__mro__:
864
+ descriptor = klass.__dict__.get(name)
865
+ if descriptor is not None and hasattr(descriptor, "_column_attribute"):
866
+ column_attr = descriptor._column_attribute
867
+ if column_attr is None or not hasattr(column_attr, "__column__"):
868
+ break
869
+ if klass is cls:
870
+ column_attr.__column__ = table.columns[name]
871
+ else:
872
+ new_col_attr = object.__new__(type(column_attr))
873
+ new_col_attr.__dict__.update(column_attr.__dict__)
874
+ new_col_attr.__column__ = table.columns[name]
875
+ new_col_attr.model_class = cls
876
+ new_descriptor = object.__new__(type(descriptor))
877
+ new_descriptor.__dict__.update(descriptor.__dict__)
878
+ new_descriptor._column_attribute = new_col_attr
879
+ type.__setattr__(cls, name, new_descriptor)
880
+ break
948
881
 
949
882
  @classmethod
950
883
  def _initialize_field_cache(mcs, cls: Any) -> None:
@@ -1011,97 +944,37 @@ class ModelProcessor(type):
1011
944
 
1012
945
 
1013
946
  def _parse_model_config(model_class: Any) -> ModelConfig:
1014
- """Parse complete configuration for a model class.
1015
-
1016
- Args:
1017
- model_class: Model class to process configuration for
1018
-
1019
- Returns:
1020
- Complete ModelConfig with all defaults filled
1021
- """
1022
947
  config_class = getattr(model_class, "Config", None)
1023
- if config_class:
1024
- raw_config = _parse_config_class(config_class)
1025
- else:
1026
- raw_config = _RawModelConfig()
948
+ raw = _parse_config_class(config_class) if config_class else _RawModelConfig()
1027
949
 
1028
- return _fill_config_defaults(raw_config, model_class)
1029
-
1030
-
1031
- def _parse_config_class(config_class: type) -> _RawModelConfig:
1032
- """Parse configuration from a Config inner class.
1033
-
1034
- Args:
1035
- config_class: The Config inner class to parse
950
+ table_name = raw.table_name or pluralize(to_snake_case(model_class.__name__))
951
+ verbose_name = raw.verbose_name or model_class.__name__
952
+ verbose_name_plural = raw.verbose_name_plural or pluralize(verbose_name)
1036
953
 
1037
- Returns:
1038
- _RawModelConfig instance with parsed configuration
1039
- """
1040
- config = _RawModelConfig()
1041
-
1042
- # Basic configuration
1043
- config.table_name = getattr(config_class, "table_name", None)
1044
- config.ordering = getattr(config_class, "ordering", [])
1045
-
1046
- # Index configuration
1047
- config.indexes = getattr(config_class, "indexes", [])
1048
-
1049
- # Constraint configuration
1050
- config.constraints = getattr(config_class, "constraints", [])
1051
-
1052
- # Metadata
1053
- config.verbose_name = getattr(config_class, "verbose_name", None)
1054
- config.verbose_name_plural = getattr(config_class, "verbose_name_plural", None)
1055
- config.description = getattr(config_class, "description", None)
1056
-
1057
- # Database-specific configuration
1058
- config.db_options = getattr(config_class, "db_options", {})
1059
-
1060
- # Custom configuration
1061
- config.custom = getattr(config_class, "custom", {})
1062
-
1063
- return config
1064
-
1065
-
1066
- def _fill_config_defaults(config: _RawModelConfig, model_class: Any) -> ModelConfig:
1067
- """Fill default values for configuration fields that are None.
1068
-
1069
- Args:
1070
- config: _RawModelConfig instance to fill defaults for
1071
- model_class: Model class to generate defaults from
1072
-
1073
- Returns:
1074
- ModelConfig instance with defaults filled
1075
- """
1076
- # Fill table_name if not set
1077
- table_name = config.table_name
1078
- if table_name is None:
1079
- snake_case_name = to_snake_case(model_class.__name__)
1080
- table_name = pluralize(snake_case_name)
1081
-
1082
- # Fill verbose_name if not set
1083
- verbose_name = config.verbose_name
1084
- if verbose_name is None:
1085
- verbose_name = model_class.__name__
1086
-
1087
- # Fill verbose_name_plural if not set
1088
- verbose_name_plural = config.verbose_name_plural
1089
- if verbose_name_plural is None:
1090
- verbose_name_plural = pluralize(verbose_name)
1091
-
1092
- # Create complete config with required fields
1093
954
  return ModelConfig(
1094
955
  table_name=table_name,
1095
956
  verbose_name=verbose_name,
1096
957
  verbose_name_plural=verbose_name_plural,
1097
- ordering=config.ordering,
1098
- indexes=config.indexes,
1099
- constraints=config.constraints,
1100
- description=config.description,
1101
- db_options=config.db_options,
1102
- custom=config.custom,
1103
- field_validators={},
1104
- field_metadata={},
958
+ ordering=raw.ordering,
959
+ indexes=raw.indexes,
960
+ constraints=raw.constraints,
961
+ description=raw.description,
962
+ db_options=raw.db_options,
963
+ custom=raw.custom,
964
+ )
965
+
966
+
967
+ def _parse_config_class(config_class: type) -> _RawModelConfig:
968
+ return _RawModelConfig(
969
+ table_name=getattr(config_class, "table_name", None),
970
+ verbose_name=getattr(config_class, "verbose_name", None),
971
+ verbose_name_plural=getattr(config_class, "verbose_name_plural", None),
972
+ ordering=getattr(config_class, "ordering", []),
973
+ indexes=getattr(config_class, "indexes", []),
974
+ constraints=getattr(config_class, "constraints", []),
975
+ description=getattr(config_class, "description", None),
976
+ db_options=getattr(config_class, "db_options", {}),
977
+ custom=getattr(config_class, "custom", {}),
1105
978
  )
1106
979
 
1107
980
 
@@ -1,5 +1,7 @@
1
1
  import asyncio
2
2
  import gc
3
+ from collections.abc import AsyncGenerator
4
+ from typing import Any, TypeVar, overload
3
5
 
4
6
  from sqlalchemy import (
5
7
  delete,
@@ -11,6 +13,9 @@ from sqlalchemy import (
11
13
  )
12
14
 
13
15
 
16
+ _T = TypeVar("_T")
17
+
18
+
14
19
  class QueryExecutor:
15
20
  """Unified query execution engine with caching and iterator support.
16
21
 
@@ -35,6 +40,14 @@ class QueryExecutor:
35
40
  readonly = query_type not in self._WRITE_TYPES
36
41
  return get_session(self._db_or_session, readonly=readonly)
37
42
 
43
+ @overload
44
+ async def execute(
45
+ self, query: Any, query_type: str = ..., *, builder: Any = ..., model_class: type[_T], **kwargs: Any
46
+ ) -> list[_T]: ...
47
+ @overload
48
+ async def execute(
49
+ self, query: Any, query_type: str = ..., *, builder: Any = ..., model_class: None = ..., **kwargs: Any
50
+ ) -> list[Any] | int | bool: ...
38
51
  async def execute(
39
52
  self,
40
53
  query,
@@ -91,7 +104,7 @@ class QueryExecutor:
91
104
 
92
105
  return result
93
106
 
94
- async def iterator(self, query, chunk_size: int = 1000):
107
+ async def iterator(self, query, chunk_size: int = 1000) -> AsyncGenerator[Any, None]:
95
108
  """Async iterator for processing large datasets in chunks."""
96
109
  offset = 0
97
110
  processed_chunks = 0
@@ -100,7 +113,7 @@ class QueryExecutor:
100
113
  chunk_query = query.offset(offset).limit(chunk_size)
101
114
  chunk = await self._execute_query(chunk_query, "all")
102
115
 
103
- if not chunk:
116
+ if not isinstance(chunk, list) or not chunk:
104
117
  break
105
118
 
106
119
  for item in chunk:
@@ -957,11 +957,12 @@ class QuerySet(Generic[T]):
957
957
 
958
958
  async def raw(self, sql: str, params: dict | None = None) -> list[T]:
959
959
  """Execute raw SQL query and return model instances."""
960
- if not self._executor.session:
960
+ session = self._executor._get_session("all")
961
+ if not session:
961
962
  return []
962
963
 
963
964
  query = text(sql)
964
- result = await self._executor.session.execute(query, params or {})
965
+ result = await session.execute(query, params or {})
965
966
 
966
967
  instances = []
967
968
  for row in result:
@@ -1007,8 +1008,9 @@ class QuerySet(Generic[T]):
1007
1008
 
1008
1009
  # Get database dialect
1009
1010
  dialect_name = "unknown"
1010
- if hasattr(self._executor, "session") and self._executor.session and hasattr(self._executor.session, "bind"):
1011
- dialect_name = self._executor.session.bind.dialect.name
1011
+ session = self._executor._get_session("all")
1012
+ if session and hasattr(session, "bind"):
1013
+ dialect_name = session.bind.dialect.name
1012
1014
 
1013
1015
  # Database-specific date expression
1014
1016
  if dialect_name == "postgresql":
@@ -1109,8 +1111,9 @@ class QuerySet(Generic[T]):
1109
1111
 
1110
1112
  # Get database dialect
1111
1113
  dialect_name = "unknown"
1112
- if hasattr(self._executor, "session") and self._executor.session and hasattr(self._executor.session, "bind"):
1113
- dialect_name = self._executor.session.bind.dialect.name
1114
+ session = self._executor._get_session("all")
1115
+ if session and hasattr(session, "bind"):
1116
+ dialect_name = session.bind.dialect.name
1114
1117
 
1115
1118
  # Database-specific datetime expression
1116
1119
  if dialect_name == "postgresql":
@@ -172,6 +172,15 @@ class AsyncSession:
172
172
  async def _handle_exception(self, exc: Exception):
173
173
  """Handle exceptions with unified error processing."""
174
174
  await self.rollback()
175
+
176
+ # Log detailed error for debugging when echo is enabled
177
+ if hasattr(self.bind, "echo") and getattr(self.bind, "echo", False):
178
+ print(f"SQLObjects Session Error: {exc}")
179
+ if hasattr(exc, "statement"):
180
+ print(f"Statement: {exc.statement}") # type: ignore[reportAttributeAccessIssue]
181
+ if hasattr(exc, "params"):
182
+ print(f"Parameters: {exc.params}") # type: ignore[reportAttributeAccessIssue]
183
+
175
184
  if isinstance(exc, SQLAlchemyError):
176
185
  raise convert_sqlalchemy_error(exc) from exc
177
186
  raise
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlobjects
3
- Version: 1.2.1
3
+ Version: 1.2.3
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>
@@ -44,7 +44,7 @@ class TestDatabaseConfig:
44
44
  return config
45
45
 
46
46
 
47
- async def test_database_connection(db_type: str, url: str) -> bool:
47
+ async def _check_database_connection(db_type: str, url: str) -> bool:
48
48
  """测试数据库连接"""
49
49
  if db_type == "postgresql":
50
50
  import asyncpg # type: ignore[reportMissingImports]
@@ -84,7 +84,7 @@ async def check_database_connection(db_type: str) -> bool:
84
84
  __import__(driver)
85
85
 
86
86
  # 测试连接
87
- return await test_database_connection(db_type, config["url"])
87
+ return await _check_database_connection(db_type, config["url"])
88
88
 
89
89
  except ImportError:
90
90
  print(f"❌ {config.get('driver')} not installed for {db_type} testing") # type: ignore[reportPossiblyUnboundVariable]
File without changes
File without changes
File without changes