matrx-orm 3.1.4__tar.gz → 3.1.6__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 (273) hide show
  1. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/PKG-INFO +1 -1
  2. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/base.py +24 -12
  3. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/expressions.py +133 -5
  4. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/extended.py +52 -16
  5. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/relations.py +19 -5
  6. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/operations/delete.py +27 -6
  7. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/operations/read.py +7 -1
  8. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/operations/update.py +18 -13
  9. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/query/builder.py +13 -6
  10. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/session/op.py +38 -6
  11. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/pyproject.toml +1 -1
  12. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_case_when.py +145 -8
  13. matrx_orm-3.1.6/tests/level1/test_composite_pk_write_paths.py +238 -0
  14. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/.gitignore +0 -0
  15. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/BASE_CLASS_METHODS.md +0 -0
  16. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/CLAUDE.md +0 -0
  17. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/MODEL_API.md +0 -0
  18. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/README.md +0 -0
  19. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/database/orm/extended/managers/ai_model_base.py +0 -0
  20. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/docs/migrations.md +0 -0
  21. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/MODULE_README.md +0 -0
  22. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/__init__.py +0 -0
  23. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/adapters/MODULE_README.md +0 -0
  24. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/adapters/__init__.py +0 -0
  25. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/adapters/async_postgresql.py +0 -0
  26. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/adapters/base_adapter.py +0 -0
  27. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/adapters/postgresql.py +0 -0
  28. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/adapters/postgrest_client_adapter.py +0 -0
  29. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/adapters/supabase_adapter.py +0 -0
  30. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/admin/__init__.py +0 -0
  31. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/admin/router.py +0 -0
  32. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/api/__init__.py +0 -0
  33. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/api/auth.py +0 -0
  34. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/api/config.py +0 -0
  35. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/api/handlers.py +0 -0
  36. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/api/protocol.py +0 -0
  37. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/api/server.py +0 -0
  38. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/associations.py +0 -0
  39. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/cache_debug.py +0 -0
  40. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/client/MODULE_README.md +0 -0
  41. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/client/__init__.py +0 -0
  42. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/client/postgres_connection.py +0 -0
  43. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/client/postgrest.py +0 -0
  44. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/client/supabase_auth.py +0 -0
  45. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/client/supabase_config.py +0 -0
  46. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/client/supabase_manager.py +0 -0
  47. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/EXTENDED-TASK.md +0 -0
  48. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/MODULE_README.md +0 -0
  49. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/RELATIONS-TASKS.md +0 -0
  50. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/TASKS.md +0 -0
  51. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/__init__.py +0 -0
  52. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/async_db_manager.py +0 -0
  53. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/config.py +0 -0
  54. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/db_function.py +0 -0
  55. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/diagnostics.py +0 -0
  56. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/fields.py +0 -0
  57. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/introspection.py +0 -0
  58. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/listen.py +0 -0
  59. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/loop_filters.py +0 -0
  60. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/model_dto.py +0 -0
  61. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/model_view.py +0 -0
  62. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/paginator.py +0 -0
  63. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/pydantic_bridge.py +0 -0
  64. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/registry.py +0 -0
  65. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/resilience.py +0 -0
  66. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/rls_session.py +0 -0
  67. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/signals.py +0 -0
  68. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/transaction.py +0 -0
  69. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/types.py +0 -0
  70. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/core/write_queue.py +0 -0
  71. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/entity.py +0 -0
  72. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/error_handling.py +0 -0
  73. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/exceptions.py +0 -0
  74. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/extended/__init__.py +0 -0
  75. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/extended/app_error_handler.py +0 -0
  76. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/middleware/__init__.py +0 -0
  77. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/middleware/base.py +0 -0
  78. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/migrations/MODULE_README.md +0 -0
  79. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/migrations/__init__.py +0 -0
  80. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/migrations/cli.py +0 -0
  81. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/migrations/ddl.py +0 -0
  82. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/migrations/diff.py +0 -0
  83. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/migrations/executor.py +0 -0
  84. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/migrations/integration.py +0 -0
  85. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/migrations/loader.py +0 -0
  86. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/migrations/operations.py +0 -0
  87. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/migrations/state.py +0 -0
  88. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/migrations/table_filter.py +0 -0
  89. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/operations/MODULE_README.md +0 -0
  90. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/operations/__init__.py +0 -0
  91. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/operations/bulk_update_values.py +0 -0
  92. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/operations/conflict.py +0 -0
  93. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/operations/conflict_writes.py +0 -0
  94. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/operations/create.py +0 -0
  95. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/operations/dynamic_admin.py +0 -0
  96. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/operations/queue_claim.py +0 -0
  97. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/operations/staging_load.py +0 -0
  98. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/py.typed +0 -0
  99. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/python_sql/MODULE_README.md +0 -0
  100. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/python_sql/__init__.py +0 -0
  101. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/python_sql/db_objects.py +0 -0
  102. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/python_sql/table_detailed_relationships.py +0 -0
  103. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/python_sql/table_typescript_relationship.py +0 -0
  104. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/query/MODULE_README.md +0 -0
  105. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/query/__init__.py +0 -0
  106. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/query/executor.py +0 -0
  107. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/MODULE_README.md +0 -0
  108. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/__init__.py +0 -0
  109. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/code_handler.py +0 -0
  110. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/columns.py +0 -0
  111. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/common.py +0 -0
  112. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/diff_preview.py +0 -0
  113. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/entity_capabilities.py +0 -0
  114. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/generator.py +0 -0
  115. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/helpers/__init__.py +0 -0
  116. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/helpers/base_generators.py +0 -0
  117. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/helpers/entity_generators.py +0 -0
  118. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/helpers/git_checker.py +0 -0
  119. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/package_wiring.py +0 -0
  120. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/relationships.py +0 -0
  121. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/runner.py +0 -0
  122. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/schema.py +0 -0
  123. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/schema_manager.py +0 -0
  124. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/tables.py +0 -0
  125. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/schema_builder/views.py +0 -0
  126. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/session/README.md +0 -0
  127. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/session/__init__.py +0 -0
  128. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/session/coalesce.py +0 -0
  129. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/session/dag.py +0 -0
  130. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/session/errors.py +0 -0
  131. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/session/fallback.py +0 -0
  132. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/session/flush.py +0 -0
  133. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/session/lifecycle.py +0 -0
  134. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/session/managed.py +0 -0
  135. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/session/reads.py +0 -0
  136. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/session/session.py +0 -0
  137. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/session/telemetry.py +0 -0
  138. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/sql_executor/MODULE_README.md +0 -0
  139. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/sql_executor/__init__.py +0 -0
  140. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/sql_executor/executor.py +0 -0
  141. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/sql_executor/queries.py +0 -0
  142. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/sql_executor/registry.py +0 -0
  143. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/sql_executor/types.py +0 -0
  144. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/sql_executor/utils.py +0 -0
  145. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/state.py +0 -0
  146. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/utils/__init__.py +0 -0
  147. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/utils/sql_utils.py +0 -0
  148. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/matrx_orm/utils/type_converters.py +0 -0
  149. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/scripts/git-branches.sh +0 -0
  150. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/scripts/publish.sh +0 -0
  151. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/scripts/release.sh +0 -0
  152. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/MODULE_README.md +0 -0
  153. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/__init__.py +0 -0
  154. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/conftest.py +0 -0
  155. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/MODULE_README.md +0 -0
  156. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/__init__.py +0 -0
  157. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_admin_db_columns.py +0 -0
  158. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_admin_search_sentinels.py +0 -0
  159. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_advisory_lock.py +0 -0
  160. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_agent_message.py +0 -0
  161. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_api_auth.py +0 -0
  162. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_api_config.py +0 -0
  163. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_api_handlers.py +0 -0
  164. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_api_protocol.py +0 -0
  165. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_array_agg_order_by.py +0 -0
  166. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_associations.py +0 -0
  167. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_bulk_update_by_pk.py +0 -0
  168. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_bulk_upsert_increment_set_fields.py +0 -0
  169. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_cache_debug.py +0 -0
  170. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_call_function.py +0 -0
  171. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_composite_pk_filter.py +0 -0
  172. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_config.py +0 -0
  173. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_conflict_target.py +0 -0
  174. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_conflict_writes.py +0 -0
  175. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_connection_codecs.py +0 -0
  176. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_connection_poison_guard.py +0 -0
  177. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_count_composite_distinct.py +0 -0
  178. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_db_function_field.py +0 -0
  179. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_ddl_generator.py +0 -0
  180. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_dirty_tracking.py +0 -0
  181. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_exceptions.py +0 -0
  182. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_execute_admin_sql.py +0 -0
  183. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_expression_primitives.py +0 -0
  184. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_fields.py +0 -0
  185. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_filter_jsonb_agg_array_index.py +0 -0
  186. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_filter_raw.py +0 -0
  187. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_from_alias.py +0 -0
  188. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_fts.py +0 -0
  189. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_func_expression_args.py +0 -0
  190. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_having_annotate_alias.py +0 -0
  191. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_insert_ignore_and_admin_primitives.py +0 -0
  192. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_introspect_rls_policies.py +0 -0
  193. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_join_and_aggregate.py +0 -0
  194. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_listen_notify.py +0 -0
  195. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_managed_write_guard.py +0 -0
  196. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_matrx_entity.py +0 -0
  197. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_migration_diff_types.py +0 -0
  198. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_migration_loader.py +0 -0
  199. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_model_instance.py +0 -0
  200. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_model_meta.py +0 -0
  201. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_order_by_expression.py +0 -0
  202. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_patch_jsonb_path.py +0 -0
  203. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_postgrest_filters.py +0 -0
  204. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_query_builder.py +0 -0
  205. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_query_error_detail.py +0 -0
  206. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_query_executor_sql.py +0 -0
  207. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_query_timeout.py +0 -0
  208. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_querybuilder_mutation_semantics.py +0 -0
  209. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_queue_claim.py +0 -0
  210. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_registry.py +0 -0
  211. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_registry_multi_database.py +0 -0
  212. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_registry_multi_schema.py +0 -0
  213. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_relations.py +0 -0
  214. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_resilience.py +0 -0
  215. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_schema_exists.py +0 -0
  216. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_session_advisory_lock.py +0 -0
  217. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_state_cache.py +0 -0
  218. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_subquery_filter_raw.py +0 -0
  219. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_subquery_in_filter.py +0 -0
  220. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_supabase_auth.py +0 -0
  221. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_supabase_config.py +0 -0
  222. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_update_case_expression.py +0 -0
  223. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_update_subquery.py +0 -0
  224. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_upsert_with_conflict.py +0 -0
  225. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_values_jsonb_decode.py +0 -0
  226. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_watched_lifecycle.py +0 -0
  227. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level1/test_write_retry_semantics.py +0 -0
  228. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level2/MODULE_README.md +0 -0
  229. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level2/__init__.py +0 -0
  230. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level2/conftest.py +0 -0
  231. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level2/test_bulk_ops.py +0 -0
  232. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level2/test_cache_integration.py +0 -0
  233. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level2/test_crud.py +0 -0
  234. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level2/test_foreign_keys.py +0 -0
  235. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level2/test_m2m.py +0 -0
  236. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level2/test_manager.py +0 -0
  237. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level2/test_migrations_live.py +0 -0
  238. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level2/test_query_execution.py +0 -0
  239. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/level2/test_schema_diff.py +0 -0
  240. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/sample_project/.env.example +0 -0
  241. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/sample_project/README.md +0 -0
  242. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/sample_project/__init__.py +0 -0
  243. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/sample_project/generate.py +0 -0
  244. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/sample_project/generated/.gitkeep +0 -0
  245. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/sample_project/matrx_orm.yaml +0 -0
  246. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/sample_project/test_schema_generation.py +0 -0
  247. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/sample_project_desktop/.env.example +0 -0
  248. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/sample_project_desktop/README.md +0 -0
  249. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/sample_project_desktop/__init__.py +0 -0
  250. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/sample_project_desktop/client_example.py +0 -0
  251. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/sample_project_desktop/client_example.ts +0 -0
  252. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/sample_project_desktop/client_supabase_example.py +0 -0
  253. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/sample_project_desktop/server.py +0 -0
  254. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/schema/entity_tests.py +0 -0
  255. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/schema/test_base_generation.py +0 -0
  256. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/schema/test_composite_pk_fk_generation.py +0 -0
  257. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/schema/test_generate_schema.py +0 -0
  258. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/schema/test_multi_schema_output.py +0 -0
  259. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/session/__init__.py +0 -0
  260. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/session/conftest.py +0 -0
  261. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/session/test_capture_poison_proof.py +0 -0
  262. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/session/test_coalesce.py +0 -0
  263. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/session/test_dag.py +0 -0
  264. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/session/test_disk_spill_fallback.py +0 -0
  265. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/session/test_flush_individual_fallback.py +0 -0
  266. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/session/test_governed_write.py +0 -0
  267. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/session/test_managed.py +0 -0
  268. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/session/test_op_enqueue_site.py +0 -0
  269. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/session/test_reads.py +0 -0
  270. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/session/test_session.py +0 -0
  271. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/test_database_name_alias.py +0 -0
  272. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/test_model_cls_refactor.py +0 -0
  273. {matrx_orm-3.1.4 → matrx_orm-3.1.6}/tests/test_sql_param_casts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matrx-orm
3
- Version: 3.1.4
3
+ Version: 3.1.6
4
4
  Summary: Async-first PostgreSQL ORM with bidirectional migrations, schema introspection, many-to-many relationships, and built-in state caching
5
5
  Project-URL: Homepage, https://github.com/AI-Matrix-Engine/aidream-current
6
6
  Project-URL: Repository, https://github.com/AI-Matrix-Engine/aidream-current
@@ -1145,12 +1145,15 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
1145
1145
 
1146
1146
  @classmethod
1147
1147
  def _single_pk_name(cls) -> str:
1148
+ """The one PK column name, for callers that structurally need a single
1149
+ scalar key (e.g. ``{pk}__in`` filters). RAISES on composite-PK models —
1150
+ use ``_build_pk_filter`` for by-id access instead."""
1148
1151
  pks = cls._meta.primary_keys
1149
1152
  if len(pks) != 1:
1150
1153
  raise ValueError(
1151
- f"{cls.__name__}.load_by_id requires a single-column primary key, "
1154
+ f"{cls.__name__} requires a single-column primary key here, "
1152
1155
  f"but the model has primary keys {pks!r}. Use get(...)/get_or_none(...) "
1153
- f"with explicit key columns instead."
1156
+ f"with explicit key columns, or pass a dict/tuple to the by-id methods."
1154
1157
  )
1155
1158
  return pks[0]
1156
1159
 
@@ -1159,17 +1162,20 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
1159
1162
  """Load a row by its primary key. Raises DoesNotExist if absent.
1160
1163
 
1161
1164
  Alias for ``get(**{pk: item_id})`` — mirrors ``BaseManager.load_by_id``.
1165
+ Composite-PK models accept a dict ``{pk: val}`` or a PK-ordered
1166
+ tuple/list (a bare scalar raises — see ``_build_pk_filter``).
1162
1167
  For a None-returning lookup use :meth:`load_by_id_or_none`.
1163
1168
  """
1164
- return await cls.get(**{cls._single_pk_name(): item_id})
1169
+ return await cls.get(**cls._build_pk_filter(item_id))
1165
1170
 
1166
1171
  @classmethod
1167
1172
  async def load_by_id_or_none(cls, item_id: Any) -> Self | None:
1168
1173
  """Load a row by its primary key, or None if absent.
1169
1174
 
1170
- Alias for ``get_or_none(**{pk: item_id})``.
1175
+ Alias for ``get_or_none(**{pk: item_id})``. Composite-PK models accept
1176
+ a dict / PK-ordered tuple, same as :meth:`load_by_id`.
1171
1177
  """
1172
- return await cls.get_or_none(**{cls._single_pk_name(): item_id})
1178
+ return await cls.get_or_none(**cls._build_pk_filter(item_id))
1173
1179
 
1174
1180
  @classmethod
1175
1181
  async def filter_items(cls, *args: Any, **kwargs: Any) -> list[Self]:
@@ -1189,7 +1195,7 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
1189
1195
  returned instance isn't needed, prefer ``update_where({pk: id}, ...)``
1190
1196
  (a single statement).
1191
1197
  """
1192
- instance = await cls.get(**{cls._single_pk_name(): item_id})
1198
+ instance = await cls.get(**cls._build_pk_filter(item_id))
1193
1199
  return await instance.update(**updates)
1194
1200
 
1195
1201
  @classmethod
@@ -1292,9 +1298,17 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
1292
1298
  raise TypeError(error_message)
1293
1299
 
1294
1300
  try:
1295
- is_update = bool(self._meta.primary_keys) and all(
1296
- getattr(self, pk, None) is not None for pk in self._meta.primary_keys
1297
- )
1301
+ pks = self._meta.primary_keys
1302
+ if len(pks) > 1:
1303
+ # Natural composite keys are fully known BEFORE the row exists
1304
+ # (a join row is constructed with both ids in hand), so "all
1305
+ # PKs set" cannot distinguish INSERT from UPDATE. Hydration is
1306
+ # the truth: only a row that came from the DB is an update.
1307
+ is_update = bool(self.__dict__.get("_from_db"))
1308
+ else:
1309
+ is_update = bool(pks) and all(
1310
+ getattr(self, pk, None) is not None for pk in pks
1311
+ )
1298
1312
  if is_update:
1299
1313
  # Write ONLY loaded/changed columns — never blindly re-write the
1300
1314
  # whole row. A full-row write from a partially-loaded instance
@@ -1424,9 +1438,7 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
1424
1438
  await delete.delete_instance(self)
1425
1439
  await StateManager.remove(self.__class__, self)
1426
1440
  except ORMException as e:
1427
- pk = (
1428
- getattr(self, self._meta.primary_keys[0], None) if self._meta.primary_keys else None
1429
- )
1441
+ pk = {p: getattr(self, p, None) for p in self._meta.primary_keys} or None
1430
1442
  e.enrich(model=self.__class__, operation="delete", args={"pk": pk})
1431
1443
  raise
1432
1444
 
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from datetime import date, datetime
4
+ from decimal import Decimal
3
5
  from typing import Any, TYPE_CHECKING
6
+ from uuid import UUID
4
7
 
5
8
  if TYPE_CHECKING:
6
9
  pass
@@ -533,6 +536,115 @@ def _render_func_arg(arg: Any, params: list[Any]) -> str:
533
536
  return f"${len(params)}"
534
537
 
535
538
 
539
+ # ---------------------------------------------------------------------------
540
+ # Type-anchoring for bound literals in a CASE branch
541
+ # ---------------------------------------------------------------------------
542
+ #
543
+ # Postgres infers a bare `$N`'s type from its surrounding context. A CASE whose
544
+ # THEN/ELSE branches are ALL bound params has NO anchor to infer from, so it
545
+ # falls back to `text` — and asyncpg then refuses to encode a Python int/bool/
546
+ # float into that text slot:
547
+ #
548
+ # invalid input for query argument $5: 0 (expected str, got int)
549
+ #
550
+ # Verified live (project txzxabzwovsujtloxrus):
551
+ # PREPARE p AS SELECT 1 FROM t ORDER BY CASE WHEN lower(name) = $1
552
+ # THEN $2 ELSE $3 END;
553
+ # -> parameter_types = {text,text,text} # $2/$3 silently become text
554
+ # ... THEN $2::bigint ELSE $3::bigint END
555
+ # -> parameter_types = {text,bigint,bigint} # anchored
556
+ #
557
+ # So an UNANCHORED CASE-branch literal is bound WITH an explicit cast: the value
558
+ # stays parameterized (zero injection surface) and Postgres gets its type anchor.
559
+ #
560
+ # 🚨 BUT THE CAST IS ONLY EMITTED WHEN THE CASE IS ACTUALLY UNANCHORED — i.e.
561
+ # when EVERY branch is a bound literal. If ANY branch is a column or expression,
562
+ # THAT branch already supplies the type and adding a cast can CHANGE the CASE's
563
+ # resolved type. This is not hypothetical (live-verified type resolution):
564
+ #
565
+ # CASE WHEN .. THEN bigint ELSE numeric END -> numeric ✅
566
+ # CASE WHEN .. THEN double precision ELSE numeric END -> double precision ❌
567
+ #
568
+ # The auto-ingest cost UPSERT does exactly this:
569
+ # SET daily_auto_rag_cost_used_usd = -- a NUMERIC (money) column
570
+ # CASE WHEN .. THEN $7 ELSE (col + $8) END -- ELSE anchors it: numeric
571
+ # Blindly casting `$7::double precision` there promotes the whole CASE to double
572
+ # precision and pushes MONEY THROUGH A FLOAT. So: literal-only CASE -> cast;
573
+ # any column/expression branch -> leave the params bare and let it anchor them.
574
+ #
575
+ # Order matters: bool before int (bool IS a subclass of int), datetime before
576
+ # date (datetime IS a subclass of date).
577
+ _PG_LITERAL_CASTS: tuple[tuple[type, str], ...] = (
578
+ (bool, "boolean"),
579
+ (int, "bigint"),
580
+ (float, "double precision"),
581
+ (Decimal, "numeric"),
582
+ (datetime, "timestamptz"),
583
+ (date, "date"),
584
+ (UUID, "uuid"),
585
+ )
586
+
587
+
588
+ def _pg_cast_for_literal(value: Any) -> str | None:
589
+ """The Postgres type to anchor a bound Python literal to, or None if unknown."""
590
+ for py_type, pg_type in _PG_LITERAL_CASTS:
591
+ if isinstance(value, py_type):
592
+ return pg_type
593
+ return None
594
+
595
+
596
+ def _is_bound_literal_branch(arg: Any) -> bool:
597
+ """True when a CASE branch is a value we BIND as a param.
598
+
599
+ A bare string is a COLUMN NAME (the Func-arg convention), an ``F``/``Func``/
600
+ nested ``Case`` is an expression, and ``None`` renders as a literal ``NULL``
601
+ — none of those are bound. Everything else is a value we bind.
602
+ """
603
+ if arg is None or isinstance(arg, str) or isinstance(arg, F) or hasattr(arg, "as_sql"):
604
+ return False
605
+ return True
606
+
607
+
608
+ def _branch_anchors_type(arg: Any) -> bool:
609
+ """True when a CASE branch SUPPLIES a type Postgres can resolve the CASE by.
610
+
611
+ A column reference or an expression does. A bound literal does not (it is the
612
+ thing that needs anchoring). ``None`` does not either — an untyped ``NULL``
613
+ resolves to whatever its siblings do, so it must NOT suppress the cast (a
614
+ `THEN NULL ELSE $1` CASE is still unanchored and still degrades to text).
615
+ """
616
+ return isinstance(arg, str) or isinstance(arg, F) or hasattr(arg, "as_sql")
617
+
618
+
619
+ def case_is_unanchored(branches: list[Any]) -> bool:
620
+ """True when NO CASE branch supplies a type — only then may we cast literals.
621
+
622
+ If even one branch is a column or an expression, it anchors the CASE and a
623
+ cast could change the resolved type (bigint/numeric -> numeric, but double
624
+ precision/numeric -> double precision, which silently degrades a money
625
+ column). See the block comment above.
626
+ """
627
+ return not any(_branch_anchors_type(b) for b in branches)
628
+
629
+
630
+ def _render_case_result(arg: Any, params: list[Any], *, cast_literals: bool) -> str:
631
+ """Render a CASE THEN/ELSE branch.
632
+
633
+ Identical to :func:`_render_func_arg` for columns/expressions. A bound
634
+ literal gets an explicit ``::type`` cast ONLY when ``cast_literals`` is set —
635
+ i.e. only when the CASE has no other branch to anchor its type.
636
+ """
637
+ if arg is None:
638
+ # An unadorned NULL is unknown-typed too, but it unifies with whatever
639
+ # the sibling branch resolves to — no cast needed, and none is possible.
640
+ return "NULL"
641
+ if not _is_bound_literal_branch(arg):
642
+ return _render_func_arg(arg, params)
643
+ params.append(arg)
644
+ cast = _pg_cast_for_literal(arg) if cast_literals else None
645
+ return f"${len(params)}::{cast}" if cast else f"${len(params)}"
646
+
647
+
536
648
  # ---------------------------------------------------------------------------
537
649
  # Case / When — conditional CASE WHEN ... THEN ... ELSE ... END expression
538
650
  # ---------------------------------------------------------------------------
@@ -552,8 +664,13 @@ class When:
552
664
 
553
665
  Usage::
554
666
 
555
- When("lower(e.name) = $1", phrase, then=0) # -> WHEN lower(e.name) = $1 THEN $2
667
+ When("lower(e.name) = $1", phrase, then=0) # -> WHEN lower(e.name) = $1 THEN $2::bigint
556
668
  When("ed.src_id = ANY($1::uuid[])", ids, then="ed.dst_id") # then = bare column, not bound
669
+
670
+ A bound ``then``/``default`` literal is rendered WITH an explicit ``::type``
671
+ cast — a CASE branch gives Postgres no type to infer from, so an unadorned
672
+ ``$N`` silently resolves to ``text`` and asyncpg then rejects a Python int
673
+ ("expected str, got int"). See :func:`_render_case_result`.
557
674
  """
558
675
 
559
676
  def __init__(self, condition: str, *condition_params: Any, then: Any) -> None:
@@ -561,7 +678,7 @@ class When:
561
678
  self.condition_params = condition_params
562
679
  self.then = then
563
680
 
564
- def as_sql(self, params: list[Any]) -> str:
681
+ def as_sql(self, params: list[Any], *, cast_literals: bool = True) -> str:
565
682
  if self.condition_params:
566
683
  from matrx_orm.query.executor import _shift_param_refs
567
684
 
@@ -569,7 +686,7 @@ class When:
569
686
  params.extend(self.condition_params)
570
687
  else:
571
688
  cond_sql = self.condition
572
- then_sql = _render_func_arg(self.then, params)
689
+ then_sql = _render_case_result(self.then, params, cast_literals=cast_literals)
573
690
  return f"WHEN {cond_sql} THEN {then_sql}"
574
691
 
575
692
  def __repr__(self) -> str:
@@ -611,11 +728,22 @@ class Case:
611
728
  self.default = default
612
729
 
613
730
  def as_sql(self, params: list[Any]) -> str:
731
+ # Cast the bound literals ONLY if nothing else in the CASE anchors its
732
+ # type. A column/expression branch (e.g. `ELSE col + $8` in the money
733
+ # UPSERT) is the anchor — casting alongside it can promote the whole
734
+ # CASE to a different type. See case_is_unanchored().
735
+ branches: list[Any] = [w.then for w in self.whens]
736
+ if self.default is not None:
737
+ branches.append(self.default)
738
+ cast_literals = case_is_unanchored(branches)
739
+
614
740
  parts = ["CASE"]
615
741
  for w in self.whens:
616
- parts.append(w.as_sql(params))
742
+ parts.append(w.as_sql(params, cast_literals=cast_literals))
617
743
  if self.default is not None:
618
- parts.append(f"ELSE {_render_func_arg(self.default, params)}")
744
+ parts.append(
745
+ f"ELSE {_render_case_result(self.default, params, cast_literals=cast_literals)}"
746
+ )
619
747
  parts.append("END")
620
748
  return " ".join(parts)
621
749
 
@@ -196,7 +196,7 @@ class BaseManager(Generic[ModelT]):
196
196
  async def _initialize_item_runtime(self, item: ModelT | None) -> ModelT | None:
197
197
  if not item:
198
198
  return None
199
- item_pk = getattr(item, self._pk_name, None)
199
+ item_pk = self._item_identity(item)
200
200
  if item_pk is not None and item_pk in self._skipped_item_ids:
201
201
  return None
202
202
  existing_dto = getattr(item, "dto", None)
@@ -274,7 +274,7 @@ class BaseManager(Generic[ModelT]):
274
274
  async def _process_item(self, item: ModelT | None) -> ModelT | None:
275
275
  if item:
276
276
  await self._initialize_item_runtime(item)
277
- self._add_to_active(item_id=getattr(item, self._pk_name))
277
+ self._add_to_active(item_id=item)
278
278
  return item
279
279
 
280
280
  @handle_errors
@@ -404,7 +404,12 @@ class BaseManager(Generic[ModelT]):
404
404
 
405
405
  @property
406
406
  def _pk_name(self) -> str:
407
- """The primary key field name for this manager's model."""
407
+ """The primary key field name for this manager's model.
408
+
409
+ ⚠️ Returns the FIRST PK column only — never use it to identify a row
410
+ on a composite-PK model. Use ``_pk_filter`` (by-id filters) or
411
+ ``_item_identity`` (row identity) instead.
412
+ """
408
413
  pks = self.model._meta.primary_keys
409
414
  return pks[0] if pks else "id"
410
415
 
@@ -434,12 +439,30 @@ class BaseManager(Generic[ModelT]):
434
439
 
435
440
  return governed_write(self.model)
436
441
 
442
+ def _item_identity(self, item: Any) -> Any:
443
+ """Full-primary-key identity of a model instance.
444
+
445
+ Single-PK: the scalar PK value. Composite-PK: a PK-ordered tuple.
446
+ Returns None when any PK component is unset — first-column-only
447
+ identity conflated distinct composite-PK rows.
448
+ """
449
+ pks = self.model._meta.primary_keys or ["id"]
450
+ if len(pks) == 1:
451
+ return getattr(item, pks[0], None)
452
+ values = tuple(getattr(item, pk, None) for pk in pks)
453
+ return None if any(v is None for v in values) else values
454
+
437
455
  def _active_item_key(self, item_or_id: Any) -> Any:
438
- """Normalize an active-set entry to a primary-key value."""
439
- pk = self._pk_name
440
- if hasattr(item_or_id, pk):
441
- return getattr(item_or_id, pk)
442
- return item_or_id
456
+ """Normalize an active-set entry to a full-primary-key value."""
457
+ pks = self.model._meta.primary_keys or ["id"]
458
+ if all(hasattr(item_or_id, pk) for pk in pks):
459
+ return self._item_identity(item_or_id)
460
+ if len(pks) == 1:
461
+ return item_or_id
462
+ # Composite raw id (dict / PK-ordered tuple) → normalized PK-ordered
463
+ # tuple. A bare scalar raises via _build_pk_filter.
464
+ pk_filter = self.model._build_pk_filter(item_or_id)
465
+ return tuple(pk_filter[pk] for pk in pks)
443
466
 
444
467
  def _add_to_active(self, item_id: Any) -> Any:
445
468
  """Add an item ID to the active set."""
@@ -517,7 +540,14 @@ class BaseManager(Generic[ModelT]):
517
540
  return await self.load_item_with_retry(**self._pk_filter(item_id))
518
541
 
519
542
  async def load_items_by_ids(self, item_ids: list[Any]) -> list[ModelT]:
520
- """Load multiple items by their primary keys."""
543
+ """Load multiple items by their primary keys.
544
+
545
+ Composite-PK models: each id is a dict / PK-ordered tuple (no
546
+ single-column ``__in`` exists for a composite key).
547
+ """
548
+ if len(self.model._meta.primary_keys or ["id"]) > 1:
549
+ items = await asyncio.gather(*(self.load_by_id(item_id) for item_id in item_ids))
550
+ return [item for item in items if item]
521
551
  return await self.load_items(**{f"{self._pk_name}__in": item_ids})
522
552
 
523
553
  async def add_active_by_id(self, item_id: Any) -> ModelT:
@@ -548,10 +578,9 @@ class BaseManager(Generic[ModelT]):
548
578
  @handle_errors
549
579
  async def get_active_items(self) -> list[ModelT]:
550
580
  """Get all active items, initialized."""
551
- pk = self._pk_name
552
581
  item_ids = [self._active_item_key(item_id) for item_id in self._active_items]
553
582
  items: list[ModelT] = await asyncio.gather(
554
- *(self._get_item_or_raise(**{pk: item_id}) for item_id in item_ids)
583
+ *(self._get_item_or_raise(**self._pk_filter(item_id)) for item_id in item_ids)
555
584
  )
556
585
  return list(items)
557
586
 
@@ -671,7 +700,14 @@ class BaseManager(Generic[ModelT]):
671
700
 
672
701
  config_name = self.model.get_database_name()
673
702
  table = self.model._meta.qualified_table_name
674
- pk = self._pk_name
703
+ pks = self.model._meta.primary_keys or ["id"]
704
+ if len(pks) > 1:
705
+ raise ValueError(
706
+ f"{self.model.__name__} has a composite primary key {tuple(pks)}; "
707
+ f"patch_jsonb_path supports single-column primary keys only. "
708
+ f"Use update_item_fields with an explicit pk dict/tuple instead."
709
+ )
710
+ pk = pks[0]
675
711
 
676
712
  from matrx_orm.core.async_db_manager import AsyncDatabaseManager
677
713
 
@@ -801,8 +837,8 @@ class BaseManager(Generic[ModelT]):
801
837
  rows_affected = await self.model.bulk_delete(objects)
802
838
 
803
839
  for item in objects:
804
- pk_val = getattr(item, self._pk_name, None)
805
- if pk_val:
840
+ pk_val = self._item_identity(item)
841
+ if pk_val is not None:
806
842
  self._remove_from_active(pk_val)
807
843
 
808
844
  vcprint(
@@ -822,8 +858,8 @@ class BaseManager(Generic[ModelT]):
822
858
  for item in objects:
823
859
  try:
824
860
  success = await self._delete_item(item)
825
- pk_val = getattr(item, self._pk_name, None)
826
- if success and pk_val:
861
+ pk_val = self._item_identity(item)
862
+ if success and pk_val is not None:
827
863
  self._remove_from_active(pk_val)
828
864
  success_count += 1
829
865
  except Exception as individual_error:
@@ -365,12 +365,26 @@ class ManyToManyReference:
365
365
  "target_model": self.target_model if isinstance(self.target_model, str) else getattr(self.target_model, '__name__', str(self.target_model)),
366
366
  }
367
367
 
368
+ @staticmethod
369
+ def _single_pk(model_cls: type[Model], side: str) -> str:
370
+ """M2M traversal keys each side by ONE column. A composite-PK endpoint
371
+ must raise — silently using the first PK column would join/mutate the
372
+ wrong junction rows."""
373
+ pks = model_cls._meta.primary_keys
374
+ if len(pks) != 1:
375
+ raise ValueError(
376
+ f"ManyToMany {side} model {model_cls.__name__} has a composite "
377
+ f"primary key {tuple(pks)}; M2M traversal supports single-column "
378
+ f"primary keys only. Query the junction table's model directly."
379
+ )
380
+ return pks[0]
381
+
368
382
  async def fetch_related(self, instance: Model) -> list[Model]:
369
383
  """Single-query JOIN: junction table + target model in one round-trip."""
370
384
  from matrx_orm.adapters import AdapterRegistry
371
385
  from matrx_orm.exceptions import ORMException
372
386
 
373
- pk_field = instance._meta.primary_keys[0]
387
+ pk_field = self._single_pk(instance.__class__, "source")
374
388
  source_value = getattr(instance, pk_field)
375
389
  if source_value is None:
376
390
  return []
@@ -383,7 +397,7 @@ class ManyToManyReference:
383
397
  target_table = target_model._meta.qualified_table_name
384
398
 
385
399
  # Determine the target PK column name (nearly always "id")
386
- target_pk = target_model._meta.primary_keys[0]
400
+ target_pk = self._single_pk(target_model, "target")
387
401
 
388
402
  sql = (
389
403
  f"SELECT t.* FROM {target_table} t "
@@ -408,7 +422,7 @@ class ManyToManyReference:
408
422
  from matrx_orm.adapters import AdapterRegistry
409
423
  from matrx_orm.exceptions import ORMException
410
424
 
411
- pk_field = instance._meta.primary_keys[0]
425
+ pk_field = self._single_pk(instance.__class__, "source")
412
426
  source_value = getattr(instance, pk_field)
413
427
 
414
428
  try:
@@ -443,7 +457,7 @@ class ManyToManyReference:
443
457
  from matrx_orm.adapters import AdapterRegistry
444
458
  from matrx_orm.exceptions import ORMException
445
459
 
446
- pk_field = instance._meta.primary_keys[0]
460
+ pk_field = self._single_pk(instance.__class__, "source")
447
461
  source_value = getattr(instance, pk_field)
448
462
 
449
463
  try:
@@ -473,7 +487,7 @@ class ManyToManyReference:
473
487
  from matrx_orm.adapters import AdapterRegistry
474
488
  from matrx_orm.exceptions import ORMException
475
489
 
476
- pk_field = instance._meta.primary_keys[0]
490
+ pk_field = self._single_pk(instance.__class__, "source")
477
491
  source_value = getattr(instance, pk_field)
478
492
 
479
493
  try:
@@ -25,7 +25,24 @@ async def bulk_delete(model_cls: type[Model], objects: Sequence[Model]) -> int:
25
25
  if not objects:
26
26
  return 0
27
27
 
28
- pk_name = model_cls._meta.primary_keys[0] if model_cls._meta.primary_keys else "id"
28
+ pk_list = model_cls._meta.primary_keys or ["id"]
29
+
30
+ if len(pk_list) > 1:
31
+ # Composite PK: no single-column `__in` exists. Delete per-row on the
32
+ # FULL key — filtering on the first column alone would wipe every row
33
+ # sharing that value.
34
+ rows_affected = 0
35
+ for obj in objects:
36
+ pk_filter = {pk: getattr(obj, pk, None) for pk in pk_list}
37
+ if any(v is None for v in pk_filter.values()):
38
+ continue
39
+ deleted = await delete(model_cls, **pk_filter)
40
+ if deleted:
41
+ rows_affected += deleted
42
+ await StateManager.remove(model_cls, obj)
43
+ return rows_affected
44
+
45
+ pk_name = pk_list[0]
29
46
  ids = [
30
47
  getattr(obj, pk_name)
31
48
  for obj in objects
@@ -66,11 +83,15 @@ async def delete_instance(instance: Model) -> None:
66
83
  pk_list = model_cls._meta.primary_keys
67
84
  if not pk_list:
68
85
  raise ValueError(f"Cannot delete {model_cls.__name__} with no primary key.")
69
- pk_name = pk_list[0]
70
- pk_value = getattr(instance, pk_name, None)
71
- if pk_value is None:
72
- raise ValueError(f"Cannot delete {model_cls.__name__}, {pk_name} is None")
86
+ # WHERE must carry EVERY primary-key column — first-column-only deletes
87
+ # every row sharing that value on a composite-PK table.
88
+ pk_filter: dict[str, Any] = {}
89
+ for pk in pk_list:
90
+ value = getattr(instance, pk, None)
91
+ if value is None:
92
+ raise ValueError(f"Cannot delete {model_cls.__name__}, primary key {pk} is None")
93
+ pk_filter[pk] = value
73
94
 
74
95
  await pre_delete.send(model_cls, instance=instance)
75
- await delete(model_cls, **{pk_name: pk_value})
96
+ await delete(model_cls, **pk_filter)
76
97
  await post_delete.send(model_cls, instance=instance)
@@ -43,7 +43,13 @@ async def values_list(model_cls, *fields, flat=False, **kwargs):
43
43
 
44
44
  async def in_bulk(model_cls, id_list, field=None):
45
45
  if field is None:
46
- field = model_cls._meta.primary_keys[0] if model_cls._meta.primary_keys else "id"
46
+ pks = model_cls._meta.primary_keys
47
+ if len(pks) > 1:
48
+ raise ValueError(
49
+ f"{model_cls.__name__} has a composite primary key {tuple(pks)} — "
50
+ f"in_bulk() needs an explicit single `field` to key results on."
51
+ )
52
+ field = pks[0] if pks else "id"
47
53
  objects = await QueryBuilder(model_cls).filter(**{f"{field}__in": id_list}).all()
48
54
  return {getattr(obj, field): obj for obj in objects}
49
55
 
@@ -34,9 +34,9 @@ async def bulk_update(model_cls: type[Model], objects: Sequence[Model], fields:
34
34
  vcprint(model_cls._fields, "Model Fields", color="yellow")
35
35
  raise ValueError(f"Invalid fields for {model_cls.__name__}: {invalid_fields}")
36
36
 
37
- pk_name = model_cls._meta.primary_keys[0] if model_cls._meta.primary_keys else "id"
37
+ pk_list = model_cls._meta.primary_keys or ["id"]
38
38
  valid_objects = [
39
- obj for obj in objects if getattr(obj, pk_name, None) is not None
39
+ obj for obj in objects if all(getattr(obj, pk, None) is not None for pk in pk_list)
40
40
  ]
41
41
  if not valid_objects:
42
42
  return 0
@@ -47,7 +47,8 @@ async def bulk_update(model_cls: type[Model], objects: Sequence[Model], fields:
47
47
  failed_updates: list[dict[str, Any]] = []
48
48
 
49
49
  for i, obj in enumerate(valid_objects):
50
- pk_value = getattr(obj, pk_name)
50
+ pk_filter = {pk: getattr(obj, pk) for pk in pk_list}
51
+ pk_value = pk_filter if len(pk_list) > 1 else next(iter(pk_filter.values()))
51
52
  try:
52
53
  update_data: dict[str, Any] = {}
53
54
  for field_name in fields:
@@ -59,7 +60,7 @@ async def bulk_update(model_cls: type[Model], objects: Sequence[Model], fields:
59
60
 
60
61
  if update_data:
61
62
  result = (
62
- await QueryBuilder(model_cls).filter(**{pk_name: pk_value}).update(**update_data)
63
+ await QueryBuilder(model_cls).filter(**pk_filter).update(**update_data)
63
64
  )
64
65
  if result.rows_affected > 0:
65
66
  rows_affected += 1
@@ -140,10 +141,14 @@ async def update_instance(instance: Model, fields: Iterable[str] | None = None)
140
141
  pk_list = model_cls._meta.primary_keys
141
142
  if not pk_list:
142
143
  raise ValueError(f"Cannot update {model_cls.__name__} with no primary key.")
143
- pk_name = pk_list[0]
144
- pk_value = getattr(instance, pk_name, None)
145
- if pk_value is None:
146
- raise ValueError(f"Cannot update {model_cls.__name__}, {pk_name} is None")
144
+ # WHERE must carry EVERY primary-key column. Filtering on pk_list[0] alone
145
+ # updated every row sharing the first key value on a composite-PK table.
146
+ pk_filter: dict[str, Any] = {}
147
+ for pk in pk_list:
148
+ value = getattr(instance, pk, None)
149
+ if value is None:
150
+ raise ValueError(f"Cannot update {model_cls.__name__}, primary key {pk} is None")
151
+ pk_filter[pk] = value
147
152
 
148
153
  version_field_name: str | None = None
149
154
  current_version: int | None = None
@@ -157,10 +162,10 @@ async def update_instance(instance: Model, fields: Iterable[str] | None = None)
157
162
  field_names: Iterable[str] = (
158
163
  fields
159
164
  if fields is not None
160
- else [f for f in model_cls._fields if f != pk_name]
165
+ else [f for f in model_cls._fields if f not in pk_filter]
161
166
  )
162
167
  for field_name in field_names:
163
- if field_name == pk_name:
168
+ if field_name in pk_filter:
164
169
  continue
165
170
  if field_name not in model_cls._fields:
166
171
  continue
@@ -182,7 +187,7 @@ async def update_instance(instance: Model, fields: Iterable[str] | None = None)
182
187
  if version_field_name is not None and update_data:
183
188
  update_data[version_field_name] = (current_version or 1) + 1
184
189
 
185
- filters: dict[str, Any] = {pk_name: pk_value}
190
+ filters: dict[str, Any] = dict(pk_filter)
186
191
  if version_field_name is not None and current_version is not None:
187
192
  filters[version_field_name] = current_version
188
193
 
@@ -198,11 +203,11 @@ async def update_instance(instance: Model, fields: Iterable[str] | None = None)
198
203
  if version_field_name is not None:
199
204
  raise OptimisticLockError(
200
205
  model=model_cls,
201
- pk=pk_value,
206
+ pk=pk_filter if len(pk_filter) > 1 else next(iter(pk_filter.values())),
202
207
  expected_version=current_version,
203
208
  )
204
209
  raise ValueError(
205
- f"No rows were updated for {model_cls.__name__} with {pk_name}={pk_value}"
210
+ f"No rows were updated for {model_cls.__name__} with {pk_filter}"
206
211
  )
207
212
 
208
213
  if result.updated_rows:
@@ -80,12 +80,18 @@ def _merge_pending_into_models(model_cls: type, db_models: list, filters: dict[s
80
80
  filters,
81
81
  )
82
82
 
83
- # Telemetry: count rows that came from Session state.
84
- db_pks = {d.get(model_cls._meta.primary_keys[0]) for d in db_dicts if isinstance(d, dict)}
83
+ # Telemetry: count rows that came from Session state. Row identity is the
84
+ # FULL primary key first-column-only would conflate composite-PK rows.
85
+ _pk_names = model_cls._meta.primary_keys or ["id"]
86
+
87
+ def _row_key(d: dict) -> tuple:
88
+ return tuple(d.get(pk) for pk in _pk_names)
89
+
90
+ db_pks = {_row_key(d) for d in db_dicts if isinstance(d, dict)}
85
91
  merged_extra = sum(
86
92
  1
87
93
  for d in merged_dicts
88
- if isinstance(d, dict) and d.get(model_cls._meta.primary_keys[0]) not in db_pks
94
+ if isinstance(d, dict) and _row_key(d) not in db_pks
89
95
  )
90
96
  if merged_extra:
91
97
  try:
@@ -805,9 +811,10 @@ class QueryBuilder(Generic[ModelT]):
805
811
  raise
806
812
 
807
813
  async def last(self) -> ModelT | None:
808
- """Order by primary key descending and fetch the first record."""
809
- pk_name = self.model._meta.primary_keys[0] if self.model._meta.primary_keys else "id"
810
- self.order_by(f"-{pk_name}")
814
+ """Order by primary key (all columns, for composite PKs) descending
815
+ and fetch the first record."""
816
+ pk_names = self.model._meta.primary_keys or ["id"]
817
+ self.order_by(*(f"-{pk}" for pk in pk_names))
811
818
  return await self.first()
812
819
 
813
820
  async def get(self) -> ModelT: