Skip to content

feat(repository): add composite primary key support#640

Open
cofin wants to merge 15 commits intolitestar-org:mainfrom
cofin:feat/composite-primary-keys
Open

feat(repository): add composite primary key support#640
cofin wants to merge 15 commits intolitestar-org:mainfrom
cofin:feat/composite-primary-keys

Conversation

@cofin
Copy link
Member

@cofin cofin commented Dec 15, 2025

Summary

This PR adds support for composite (multi-column) primary keys throughout the repository and service layers. Now you can work with association tables, legacy databases with natural keys, or any model that uses multiple columns as its primary key.

What's Changed

Repository Layer

  • New PrimaryKeyType type alias that accepts scalar values, tuples, or dicts
  • Helper methods for composite key handling (_build_pk_filter(), _extract_pk_value(), etc.)
  • get(), delete(), and delete_many() now work with composite keys
  • Uses tuple_().in_() for efficient bulk operations
  • PK columns are cached in __init__ for better performance

MSSQL Compatibility

  • MSSQL doesn't support row value comparisons like WHERE (col1, col2) IN ((v1, v2), ...)
  • Added a dialect-specific fallback that uses OR/AND conditions instead
  • Automatically detected and applied when using MSSQL

Service Layer

  • get(), delete(), and delete_many() signatures updated to accept composite keys
  • Added docstrings with usage examples

Memory Repository

  • Full composite PK support for testing
  • New helper properties and methods for key handling

Documentation

  • Added "Composite Primary Keys" section to repositories docs with examples
  • Added brief composite PK section to services docs with cross-reference

CI Fix

  • Added sphinx to mypy's skip-imports list to fix Python 3.12+ syntax errors

Usage

# Get by composite key (tuple format - values in PK column order)
assignment = await repo.get((user_id, role_id))

# Get by composite key (dict format - explicit column names)
assignment = await repo.get({"user_id": 1, "role_id": 5})

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

# Services work the same way
item = await service.get((tenant_id, item_id))
await service.delete({"tenant_id": 1, "item_id": 42})

Follow-up

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

Closes #189

@codecov-commenter
Copy link

codecov-commenter commented Dec 15, 2025

Codecov Report

❌ Patch coverage is 52.58964% with 119 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.70%. Comparing base (e306121) to head (8485c21).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
advanced_alchemy/repository/_sync.py 39.78% 43 Missing and 13 partials ⚠️
advanced_alchemy/repository/_async.py 79.56% 12 Missing and 7 partials ⚠️
advanced_alchemy/repository/memory/_async.py 32.14% 19 Missing ⚠️
advanced_alchemy/repository/memory/_sync.py 32.14% 19 Missing ⚠️
advanced_alchemy/repository/memory/base.py 0.00% 6 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #640      +/-   ##
==========================================
- Coverage   81.17%   80.70%   -0.47%     
==========================================
  Files          99       99              
  Lines        7990     8159     +169     
  Branches     1079     1116      +37     
==========================================
+ Hits         6486     6585      +99     
- Misses       1180     1244      +64     
- Partials      324      330       +6     

☔ 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.

@cofin cofin marked this pull request as draft December 20, 2025 17:40
@cofin cofin force-pushed the feat/composite-primary-keys branch 2 times, most recently from 345f3c8 to ef6a288 Compare January 17, 2026 22:33
@cofin cofin force-pushed the feat/composite-primary-keys branch from 0213b6b to 6835f05 Compare January 18, 2026 18:26
@cofin cofin marked this pull request as ready for review January 19, 2026 20:41
@cofin cofin force-pushed the feat/composite-primary-keys branch from 9f0fcab to 1e6808b Compare January 19, 2026 22:10
@cofin cofin force-pushed the feat/composite-primary-keys branch 2 times, most recently from 5acfbbd to e4e8559 Compare February 5, 2026 00:02
cofin added 9 commits February 6, 2026 12:18
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)
- 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
…primary key handling in repositories and services.
cofin added 6 commits February 6, 2026 12:18
MSSQL doesn't support tuple().in_() syntax for row value comparisons.
Use OR of AND conditions as fallback for MSSQL dialect.

Also fix mock repositories in unit tests to initialize _pk_columns
and _pk_attr_names attributes required by composite PK support.
…ices, including a fix for MSSQL tuple().in_() syntax.
…documentation to use it, including minor heading style adjustment.
@cofin cofin force-pushed the feat/composite-primary-keys branch from e4e8559 to 8485c21 Compare February 6, 2026 18:18
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