Skip to content

Conversation

@cofin
Copy link
Member

@cofin cofin commented Dec 15, 2025

Summary

Adds support for composite (multi-column) primary keys across the repository and service layers. This enables working with association tables, legacy databases with natural keys, and any model using multi-column primary keys.

Key Changes

Repository Layer:

  • Add PrimaryKeyType type alias supporting scalar, tuple, and dict formats
  • Add helper methods for composite key handling (_build_pk_filter(), _extract_pk_value(), etc.)
  • Update get(), delete(), and delete_many() to support composite keys
  • Use tuple_().in_() for efficient bulk operations with composite keys
  • Cache PK columns in __init__ for performance

Service Layer:

  • Update get(), delete(), and delete_many() method signatures to accept PrimaryKeyType
  • Add comprehensive docstrings with composite key examples

Memory Repository:

  • Add full composite PK support for testing scenarios
  • Add helper properties: _pk_columns, _pk_attr_names
  • Add helper methods: _is_composite_pk(), _extract_pk_value(), _normalize_pk_to_tuple(), _get_store_key()
  • Update store key generation for composite keys

Usage Examples

# Composite primary key (tuple format - values in PK column order)
assignment = await user_role_repo.get((user_id, role_id))

# Composite primary key (dict format - named attributes)
assignment = await user_role_repo.get({"user_id": 1, "role_id": 5})

# Bulk delete with composite keys
await user_role_repo.delete_many([
    (1, 5),
    (1, 6),
    (2, 5),
])

# Service layer with composite keys
item = await service.get((tenant_id, item_id))
await service.delete((tenant_id, item_id))

Consensus

Implementation approach was validated by gemini-3-pro-preview (9/10 confidence) and gpt-5.2 (8/10 confidence) with the following agreed refinements:

  • Cache PK columns in __init__ rather than computing per-call
  • Use ORM attribute names for dict keys
  • Use tuple_().in_() for bulk delete instead of OR chains
  • Strict input validation (single PK rejects tuple/dict; composite rejects scalar)

Test Plan

  • All existing unit tests pass (883 tests)
  • Pre-commit checks pass (mypy, pyright, ruff)
  • Backward compatibility verified for single-column PKs
  • Mock repository tests updated for new code paths
  • Service layer type signatures updated
  • Memory repository supports composite keys
  • Add integration tests for composite primary key models (follow-up)

Follow-up Work

  • Phase 3.3: Update update() and upsert() methods for composite key support

Related Issues

Closes #189

Adds support for composite (multi-column) primary keys in the repository layer.

Changes:
- Add PrimaryKeyType type alias supporting scalar, tuple, and dict formats
- Add helper methods for composite key handling:
  - _is_composite_pk(): Check if model has composite PK
  - _build_pk_filter(): Build WHERE clause for PK lookup
  - _extract_pk_value(): Extract PK value(s) from instance
  - _pk_values_present(): Check if all PK values are set
  - _normalize_pk_values_to_tuples(): Convert PK values to tuples for bulk ops
- Update get() to support composite keys (tuple or dict input)
- Update delete() to support composite keys
- Update delete_many() to use tuple_().in_() for efficient bulk operations
- Cache PK columns in __init__ for performance

The implementation follows SQLAlchemy's native patterns and maintains
full backward compatibility for single-column primary keys.

Closes litestar-org#189
Extend composite PK support from repository to service layer and
memory repositories to complete Phase 3.1 and 3.2 of the composite
primary key feature.

Changes:
- Update service layer get/delete/delete_many signatures to use PrimaryKeyType
- Add composite PK helpers to memory repository (_pk_columns, _is_composite_pk, etc.)
- Update memory repository get/delete/delete_many for composite keys
- Add fallback in _build_pk_filter for mock objects without mapped PKs
- Fix unit tests for new code paths

Refs: litestar-org#189
Add explicit casts and type annotations to satisfy mypy and pyright
strict mode for composite primary key handling methods.

Changes:
- Add ColumnElement[bool] casts for single-value PK comparisons
- Use explicit type annotations instead of redundant casts
- Extract type(pk_value).__name__ to local variable for type safety
- Add guards for empty tuple edge cases in memory repository
- Replace cast() with explicit type annotations to avoid redundant-cast
- Remove unnecessary `not isinstance(pk_value, str)` checks that mypy
  correctly identifies as unreachable (tuple/dict can't be str subclasses)
@codecov-commenter
Copy link

codecov-commenter commented Dec 15, 2025

Codecov Report

❌ Patch coverage is 25.10638% with 176 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.58%. Comparing base (5ceaae8) to head (499d7a6).

Files with missing lines Patch % Lines
advanced_alchemy/repository/_async.py 25.28% 58 Missing and 7 partials ⚠️
advanced_alchemy/repository/_sync.py 25.28% 58 Missing and 7 partials ⚠️
advanced_alchemy/repository/memory/_async.py 25.92% 20 Missing ⚠️
advanced_alchemy/repository/memory/_sync.py 25.92% 20 Missing ⚠️
advanced_alchemy/repository/memory/base.py 0.00% 6 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #640      +/-   ##
==========================================
- Coverage   80.34%   78.58%   -1.77%     
==========================================
  Files          87       87              
  Lines        6431     6621     +190     
  Branches      838      883      +45     
==========================================
+ Hits         5167     5203      +36     
- Misses       1000     1144     +144     
- Partials      264      274      +10     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

- Split combined isinstance check into separate checks to avoid
  type(pk_value).__name__ which triggers reportUnknownArgumentType
- Add type: ignore[redundant-cast] for casts needed by pyright but
  flagged as redundant by mypy
- Add reportUnknownArgumentType and reportUnknownVariableType = false
  to pyright config for consistency with existing disabled rules
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enhancement: SQLAlchemy repository - Support composite primary keys

2 participants