matrx-orm 3.1.51__tar.gz → 3.1.52__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.
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/BASE_CLASS_METHODS.md +22 -3
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/CLAUDE.md +1 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/PKG-INFO +1 -1
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/base.py +69 -11
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/extended.py +11 -4
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/entity.py +80 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/exceptions.py +16 -3
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/update.py +182 -10
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/pyproject.toml +1 -1
- matrx_orm-3.1.52/tests/level1/test_optimistic_concurrency.py +375 -0
- matrx_orm-3.1.52/tests/level1/test_update_with_rebase.py +162 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/.gitignore +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/AGENTS.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/MODEL_API.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/README.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/database/orm/extended/managers/ai_model_base.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/docs/migrations.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/MODULE_README.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/adapters/MODULE_README.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/adapters/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/adapters/async_postgresql.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/adapters/base_adapter.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/adapters/postgresql.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/adapters/postgrest_client_adapter.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/adapters/supabase_adapter.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/admin/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/admin/router.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/api/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/api/auth.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/api/config.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/api/handlers.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/api/protocol.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/api/server.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/associations.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/cache_debug.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/catalog.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/client/MODULE_README.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/client/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/client/postgres_connection.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/client/postgrest.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/client/supabase_auth.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/client/supabase_config.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/client/supabase_manager.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/EXTENDED-TASK.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/FEATURE.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/MODULE_README.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/RELATIONS-TASKS.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/TASKS.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/async_db_manager.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/config.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/connections.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/db_function.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/diagnostics.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/expressions.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/fields.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/introspection.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/listen.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/loop_filters.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/model_dto.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/model_view.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/one_database.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/paginator.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/pool_maintenance.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/pydantic_bridge.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/registry.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/relations.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/resilience.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/rls_session.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/signals.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/tls.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/transaction.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/types.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/write_queue.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/error_handling.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/extended/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/extended/app_error_handler.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/identity_rule.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/middleware/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/middleware/base.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/MODULE_README.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/cli.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/ddl.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/diff.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/executor.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/integration.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/loader.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/operations.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/state.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/table_filter.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/MODULE_README.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/bulk_update_values.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/conflict.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/conflict_writes.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/create.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/db_functions.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/delete.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/dynamic_admin.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/dynamic_crud.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/graph_walk.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/latest_rows.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/queue_claim.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/read.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/relational_backfill.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/staging_load.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/platform_db.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/py.typed +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/python_sql/MODULE_README.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/python_sql/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/python_sql/db_objects.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/python_sql/table_detailed_relationships.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/python_sql/table_typescript_relationship.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/query/FEATURE.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/query/MODULE_README.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/query/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/query/builder.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/query/executor.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/MODULE_README.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/code_handler.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/codegen_writer.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/columns.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/common.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/diff_preview.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/entity_capabilities.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/generator.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/helpers/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/helpers/base_generators.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/helpers/entity_generators.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/helpers/git_checker.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/package_wiring.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/relationships.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/runner.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/schema.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/schema_manager.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/tables.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/views.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/secrets_battery/FEATURE.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/secrets_battery/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/secrets_battery/crypto.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/secrets_battery/items.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/secrets_battery/model.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/secrets_battery/org_service.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/secrets_battery/service.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/README.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/coalesce.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/dag.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/errors.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/fallback.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/flush.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/lifecycle.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/managed.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/op.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/reads.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/session.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/telemetry.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/sql_executor/MODULE_README.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/sql_executor/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/sql_executor/executor.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/sql_executor/queries.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/sql_executor/registry.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/sql_executor/types.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/sql_executor/utils.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/state.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/utils/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/utils/sql_utils.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/utils/type_converters.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/scripts/git-branches.sh +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/scripts/publish.sh +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/scripts/release.sh +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/MODULE_README.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/conftest.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/MODULE_README.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_admin_db_columns.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_admin_search_negation.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_admin_search_sentinels.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_advisory_lock.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_agent_message.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_api_auth.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_api_config.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_api_handlers.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_api_protocol.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_array_agg_order_by.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_associations.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_bulk_update_by_pk.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_bulk_upsert_increment_set_fields.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_cache_debug.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_call_function.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_case_when.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_catalog.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_composite_pk_filter.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_composite_pk_write_paths.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_config.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_conflict_target.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_conflict_writes.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_connection_codecs.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_connection_poison_guard.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_connection_primitives.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_count_composite_distinct.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_db_function_field.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_ddl_generator.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_derived_table.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_dirty_tracking.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_dynamic_crud.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_exceptions.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_execute_query_integrity_mapping.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_expression_primitives.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_fields.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_filter_jsonb_agg_array_index.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_from_alias.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_fts.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_func_expression_args.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_graph_walk.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_having_annotate_alias.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_insert_ignore_and_admin_primitives.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_introspect_rls_policies.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_join_and_aggregate.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_latest_rows.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_listen_notify.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_managed_write_guard.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_matrx_entity.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_migration_diff_types.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_migration_loader.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_model_instance.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_model_meta.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_order_by_expression.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_parameter_name_hydration.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_patch_jsonb_path.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_pool_loop_recreation.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_pool_maintenance.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_postgrest_filters.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_pydantic_bridge.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_query_builder.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_query_error_detail.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_query_executor_sql.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_query_timeout.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_querybuilder_clone_semantics.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_queue_claim.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_registry.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_registry_multi_database.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_registry_multi_schema.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_relational_backfill.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_relations.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_resilience.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_schema_exists.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_session_advisory_lock.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_state_cache.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_subquery_filter_raw.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_subquery_in_filter.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_supabase_auth.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_supabase_config.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_tls.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_typed_join_projection.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_typed_predicates.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_update_case_expression.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_update_subquery.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_upsert_default_leak.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_upsert_with_conflict.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_values_jsonb_decode.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_watched_lifecycle.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_write_retry_semantics.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/MODULE_README.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/conftest.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/test_bulk_ops.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/test_cache_integration.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/test_crud.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/test_foreign_keys.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/test_m2m.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/test_manager.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/test_migrations_live.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/test_query_execution.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/test_schema_diff.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project/.env.example +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project/README.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project/generate.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project/generated/.gitkeep +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project/matrx_orm.yaml +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project/test_schema_generation.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project_desktop/.env.example +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project_desktop/README.md +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project_desktop/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project_desktop/client_example.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project_desktop/client_example.ts +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project_desktop/client_supabase_example.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project_desktop/server.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/entity_tests.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_base_generation.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_composite_pk_fk_generation.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_composite_unique_generation.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_dto_identity_generation.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_entity_capability_generation.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_generate_schema.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_junction_analysis.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_multi_schema_output.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_sql_expression_defaults.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_view_generation.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema_builder/test_focused_generation_plan.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/__init__.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/conftest.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_cache_no_downgrade.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_capture_poison_proof.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_coalesce.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_dag.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_disk_spill_fallback.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_flush_individual_fallback.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_governed_write.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_managed.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_op_enqueue_site.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_reads.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_session.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/test_database_name_alias.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/test_db_functions.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/test_model_cls_refactor.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/test_no_blocking_asyncpg_tls.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/test_one_database.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/test_secrets_battery.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/test_sql_param_casts.py +0 -0
- {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/test_sync_twins_guard.py +0 -0
|
@@ -49,7 +49,7 @@ The following methods are available directly from all manager classes:
|
|
|
49
49
|
### CRUD & Bulk
|
|
50
50
|
- `create_item(**data)`
|
|
51
51
|
- `create_items(items_data, batch_size=1000, ignore_conflicts=False)`
|
|
52
|
-
- `update_item(item_id, **updates)`
|
|
52
|
+
- `update_item(item_id, *, expected_version=None, **updates)`
|
|
53
53
|
- `update_item_fields(item_id, **updates)`
|
|
54
54
|
- `update_items(objects, fields, batch_size=1000)`
|
|
55
55
|
- `delete_item(item_id)`
|
|
@@ -59,8 +59,27 @@ The following methods are available directly from all manager classes:
|
|
|
59
59
|
- `upsert_item(data, conflict_fields, update_fields=None)`
|
|
60
60
|
- `upsert_items(items_data, conflict_fields, update_fields=None)`
|
|
61
61
|
- `count(**filters)`
|
|
62
|
-
- `update_where(filters, **updates)`
|
|
63
|
-
- `delete_where(**filters)`
|
|
62
|
+
- `update_where(filters, *, expected_version=None, **updates)`
|
|
63
|
+
- `delete_where(*, expected_version=None, **filters)`
|
|
64
|
+
|
|
65
|
+
### Optimistic concurrency — `expected_version=`
|
|
66
|
+
|
|
67
|
+
**Opt-in compare-and-swap on the canonical `version` int column.** Pass
|
|
68
|
+
`expected_version=N` to `update_where` / `update_item` / instance `update()` /
|
|
69
|
+
`delete_where`: the statement gains `WHERE version = N` (updates also
|
|
70
|
+
`SET version = N + 1`). **Single-row paths raise on 0 rows** — updates:
|
|
71
|
+
`DoesNotExist` when the row is gone, `OptimisticLockError` (with
|
|
72
|
+
`current_version` + `current_row`) when it changed; guarded `delete_where`:
|
|
73
|
+
gone → returns 0 (idempotent), changed → `OptimisticLockError`. Guarded
|
|
74
|
+
`update_where`/`delete_where` require every PK column in the filters
|
|
75
|
+
(single-row addressing). **Bulk is different:** `bulk_update(...,
|
|
76
|
+
check_version=True)` guards each row on its loaded `version` but NEVER raises
|
|
77
|
+
to the caller — a conflicting row is logged loudly and excluded from the
|
|
78
|
+
returned count. Omit the parameter → exact legacy last-write-wins.
|
|
79
|
+
`MatrxEntity.update_with_rebase(id, expected_version=N, **fields)` additionally
|
|
80
|
+
auto-rebases past conflicts whose intervening writes were DISJOINT from the
|
|
81
|
+
written fields (proven from `history.row_versions` snapshots). Cross-repo
|
|
82
|
+
contract: `common-docs/systems/optimistic-concurrency/FEATURE.md`.
|
|
64
83
|
|
|
65
84
|
### Atomic queue claims
|
|
66
85
|
|
|
@@ -23,6 +23,7 @@ Every table that becomes a Model needs row identity: a single-column primary key
|
|
|
23
23
|
- 🚨 **ONE CONNECTION, REQUIRED** (`matrx_orm.platform_db`): `register_platform_db(name, ...)` + `platform_connection_url()` — the ONLY sanctioned way any Matrx service or package turns env vars into a pool. One set of variables (`SUPABASE_MATRIX_*`), owned here; TLS is a deployment input (`SUPABASE_MATRIX_SSL`), never hardcoded; **RAISES** if it isn't given a database — no degraded mode. Every package's `bootstrap_db()` is one call to it. A package may absolutely run on its OWN Postgres — that is a change of VALUES, never a new variable name; what it may never have is a *second candidate* or a fallback chain. `register_database_from_env` is the low-level primitive underneath — **application and package code must not call it directly** (guard: `scripts/check_one_database.py`, blocking in release).
|
|
24
24
|
- **Core model layer**: `Model`, `BaseManager`, `BaseDTO`, `ModelView`, `model_registry`, plus 50+ field types (`CharField`, `IntegerField`, `ForeignKey`, `ManyToManyField`, `JSONField`, …).
|
|
25
25
|
- **`MatrxEntity`** (`matrx_orm/entity.py`) — **the platform entity contract as an ORM primitive.** A table registered in `platform.entity_types` generates as a `MatrxEntity` (everything else stays a plain `Model`) and gets the behavior instead of every consumer hand-wiring it: `await page.versions()` (not a query on `history.row_versions`), `await page.restore(3)` (not a `version_restore` RPC + a cache bust — `restore()` busts the cache itself), `Note.alive()` / `.dead()` / `.soft_delete()` / `.undelete()`, and `Page.capabilities()`. Capability flags (`_entity_token`, `_is_versioned`, `_has_soft_delete`, `_is_org_scoped`, `_rls_variant`) are **stamped by the generator from the LIVE DATABASE** (does the table really carry the `_version_capture` trigger? a `deleted_at` column?) — never from `entity_types`' own flags, which are wrong on 61 of its 242 rows. **The database is truth; a flag is a claim.** Using a capability an entity does not declare RAISES (`EntityCapabilityError`) and names the fix — it never returns silently-wrong data, and `is_deleted` refuses to answer from a column a partial fetch never loaded. Drift guard: `scripts/check_entity_drift.py` (loud, non-blocking, in `release.sh`).
|
|
26
|
+
- **Optimistic concurrency (`expected_version=`)** — opt-in compare-and-swap on the canonical `version` int column: `update_where` / `update_item` / instance `update()` take `expected_version=N` (`WHERE version = N`, `SET version = N + 1`; 0 rows → `DoesNotExist` if gone, `OptimisticLockError` with `current_version` + `current_row` if changed); `bulk_update(..., check_version=True)` guards per row; `MatrxEntity.update_with_rebase` auto-rebases past DISJOINT concurrent writes using `history.row_versions` snapshots. **Never automatic** — an unguarded write stays last-write-wins (the `_touch_row` trigger bumps `version` on every update, so auto-guarding would raise spurious conflicts). Contract: `common-docs/systems/optimistic-concurrency/FEATURE.md`.
|
|
26
27
|
- **Change tracking** — every instance records which columns were **loaded** (a SELECTed row, or the caller's kwargs) and which were **changed**. `save()` writes only those, so `.only("id","email")` + `save()` can never NULL the columns it never fetched (it used to — silent data loss). Mutable containers (jsonb/arrays) are always rewritten when loaded, because `obj.metadata["k"]=1` cannot be seen by `__setattr__`; **reassigning** (`obj.metadata = {...}`) is tracked precisely and is the sanctioned way. `Model._hydrate()` is the ONE row→model entry point.
|
|
27
28
|
- **Upsert writes only what the caller supplied.** `upsert` / `bulk_upsert` / `upsert_with_conflict` materialize every field's Python default so the **INSERT** half is complete — but `ON CONFLICT DO UPDATE SET` covers **only the columns present in the caller's `data`** (an explicit `update_fields` still wins). Letting defaults reach the UPDATE half silently RESET every defaulted column on each re-upsert of an existing row: it once reset scraped-and-analyzed `research.rs_source` rows to `scrape_status='pending', server_attempts=0` (a watchdog then finalized them `'failed'`) and undid users' manual `is_included=false`. A caller-supplied `None` is excluded too — it is dropped from the INSERT, and `EXCLUDED.<col>` on an omitted column resolves to that column's **DEFAULT**. An upsert of only conflict keys means "ensure this row exists" and reconciles to a no-op self-assignment, never a raise (a bare `DO NOTHING` would break `RETURNING *`). Pinned by `tests/level1/test_upsert_default_leak.py`.
|
|
28
29
|
- **Read-only models** (`_read_only = True`): a model that can be queried but NEVER written through the ORM — every write path raises `ReadOnlyModelError` (enforced at `QueryExecutor.insert/bulk_insert/upsert/update/delete`, so it covers all of `create`/`save`/`update`/`delete`/`update_where`/`delete_where`/`upsert`/`bulk_*`). Used for DB-managed tables the app must not mutate. The canonical **`auth.users`** model is baked into the schema generator (`get_string_user_model`) as the full, curated Supabase auth schema (safe columns only — no password/token fields), `_read_only`, bound per-database. Read a user's profile/metadata there; mutate users only through Supabase Auth (GoTrue).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: matrx-orm
|
|
3
|
-
Version: 3.1.
|
|
3
|
+
Version: 3.1.52
|
|
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
|
|
@@ -971,11 +971,24 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
|
|
|
971
971
|
return run_sync(cls.exists(**kwargs))
|
|
972
972
|
|
|
973
973
|
@classmethod
|
|
974
|
-
async def update_where(
|
|
974
|
+
async def update_where(
|
|
975
|
+
cls,
|
|
976
|
+
filters: dict[str, Any],
|
|
977
|
+
*,
|
|
978
|
+
expected_version: int | None = None,
|
|
979
|
+
**updates: Any,
|
|
980
|
+
) -> UpdateResult:
|
|
975
981
|
"""Bulk-update rows matching filters without fetching them first.
|
|
976
982
|
|
|
977
983
|
Args:
|
|
978
984
|
filters: Lookup kwargs (same syntax as filter()).
|
|
985
|
+
expected_version: opt-in optimistic concurrency — compare-and-swap
|
|
986
|
+
against the canonical ``version`` column. Requires the filters
|
|
987
|
+
to address ONE row (every primary-key column present as a plain
|
|
988
|
+
equality). 0 rows then RAISES: DoesNotExist when the row is
|
|
989
|
+
gone, OptimisticLockError (with ``current_version`` and
|
|
990
|
+
``current_row``) when it changed since the declared revision.
|
|
991
|
+
Omitted → exact legacy behavior (last-write-wins, no raise).
|
|
979
992
|
**updates: Field=value pairs to SET.
|
|
980
993
|
|
|
981
994
|
Returns:
|
|
@@ -988,13 +1001,30 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
|
|
|
988
1001
|
)
|
|
989
1002
|
print(result.rows_affected)
|
|
990
1003
|
"""
|
|
1004
|
+
if expected_version is not None:
|
|
1005
|
+
missing = [pk for pk in cls._meta.primary_keys if pk not in filters]
|
|
1006
|
+
if missing:
|
|
1007
|
+
raise ValueError(
|
|
1008
|
+
f"{cls.__name__}.update_where(expected_version=...) is a "
|
|
1009
|
+
f"single-row compare-and-swap: the filters must contain every "
|
|
1010
|
+
f"primary-key column as a plain equality (missing: {missing})."
|
|
1011
|
+
)
|
|
1012
|
+
return await update.guarded_update(
|
|
1013
|
+
cls, dict(filters), dict(updates), expected_version=expected_version
|
|
1014
|
+
)
|
|
991
1015
|
return await QueryBuilder(cls).filter(**filters).update(**updates)
|
|
992
1016
|
|
|
993
1017
|
@classmethod
|
|
994
|
-
async def delete_where(cls, **filters: Any) -> int:
|
|
1018
|
+
async def delete_where(cls, *, expected_version: int | None = None, **filters: Any) -> int:
|
|
995
1019
|
"""Delete rows matching filters without fetching them first.
|
|
996
1020
|
|
|
997
1021
|
Args:
|
|
1022
|
+
expected_version: opt-in optimistic concurrency — only delete if the
|
|
1023
|
+
canonical ``version`` column still matches. Requires every
|
|
1024
|
+
primary-key column in the filters (single-row addressing). Row
|
|
1025
|
+
already gone → returns 0 (idempotent); row present at a
|
|
1026
|
+
different version → OptimisticLockError (someone rewrote what
|
|
1027
|
+
the caller decided to delete). Omitted → legacy behavior.
|
|
998
1028
|
**filters: Lookup kwargs (same syntax as filter()).
|
|
999
1029
|
|
|
1000
1030
|
Returns:
|
|
@@ -1003,6 +1033,17 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
|
|
|
1003
1033
|
Usage:
|
|
1004
1034
|
deleted = await MyModel.delete_where(status="expired")
|
|
1005
1035
|
"""
|
|
1036
|
+
if expected_version is not None:
|
|
1037
|
+
missing = [pk for pk in cls._meta.primary_keys if pk not in filters]
|
|
1038
|
+
if missing:
|
|
1039
|
+
raise ValueError(
|
|
1040
|
+
f"{cls.__name__}.delete_where(expected_version=...) is a "
|
|
1041
|
+
f"single-row compare-and-swap: the filters must contain every "
|
|
1042
|
+
f"primary-key column as a plain equality (missing: {missing})."
|
|
1043
|
+
)
|
|
1044
|
+
return await update.guarded_delete(
|
|
1045
|
+
cls, dict(filters), expected_version=expected_version
|
|
1046
|
+
)
|
|
1006
1047
|
return await QueryBuilder(cls).filter(**filters).delete()
|
|
1007
1048
|
|
|
1008
1049
|
@classmethod
|
|
@@ -1191,15 +1232,18 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
|
|
|
1191
1232
|
return await cls.create(**data)
|
|
1192
1233
|
|
|
1193
1234
|
@classmethod
|
|
1194
|
-
async def update_item(
|
|
1235
|
+
async def update_item(
|
|
1236
|
+
cls, item_id: Any, *, expected_version: int | None = None, **updates: Any
|
|
1237
|
+
) -> Self:
|
|
1195
1238
|
"""Update a row by primary key and return it. Raises DoesNotExist if absent.
|
|
1196
1239
|
|
|
1197
1240
|
Mirrors ``BaseManager.update_item``: SELECT by pk, then UPDATE. When the
|
|
1198
1241
|
returned instance isn't needed, prefer ``update_where({pk: id}, ...)``
|
|
1199
|
-
(a single statement).
|
|
1242
|
+
(a single statement). ``expected_version`` opts into optimistic
|
|
1243
|
+
concurrency — see :meth:`update`.
|
|
1200
1244
|
"""
|
|
1201
1245
|
instance = await cls.get(**cls._build_pk_filter(item_id))
|
|
1202
|
-
return await instance.update(**updates)
|
|
1246
|
+
return await instance.update(expected_version=expected_version, **updates)
|
|
1203
1247
|
|
|
1204
1248
|
@classmethod
|
|
1205
1249
|
async def get_or_create(
|
|
@@ -1264,7 +1308,13 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
|
|
|
1264
1308
|
return run_sync(cls.upsert(data, conflict_fields, update_fields, on_conflict=on_conflict))
|
|
1265
1309
|
|
|
1266
1310
|
@classmethod
|
|
1267
|
-
def update_where_sync(
|
|
1311
|
+
def update_where_sync(
|
|
1312
|
+
cls,
|
|
1313
|
+
filters: dict[str, Any],
|
|
1314
|
+
*,
|
|
1315
|
+
expected_version: int | None = None,
|
|
1316
|
+
**updates: Any,
|
|
1317
|
+
) -> UpdateResult:
|
|
1268
1318
|
"""Synchronous twin of update_where(). Sync contexts only."""
|
|
1269
1319
|
try:
|
|
1270
1320
|
asyncio.get_running_loop()
|
|
@@ -1275,10 +1325,10 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
|
|
|
1275
1325
|
if "no running event loop" not in str(e):
|
|
1276
1326
|
raise
|
|
1277
1327
|
|
|
1278
|
-
return run_sync(cls.update_where(filters, **updates))
|
|
1328
|
+
return run_sync(cls.update_where(filters, expected_version=expected_version, **updates))
|
|
1279
1329
|
|
|
1280
1330
|
@classmethod
|
|
1281
|
-
def delete_where_sync(cls, **filters: Any) -> int:
|
|
1331
|
+
def delete_where_sync(cls, *, expected_version: int | None = None, **filters: Any) -> int:
|
|
1282
1332
|
"""Synchronous twin of delete_where(). Sync contexts only."""
|
|
1283
1333
|
try:
|
|
1284
1334
|
asyncio.get_running_loop()
|
|
@@ -1289,7 +1339,7 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
|
|
|
1289
1339
|
if "no running event loop" not in str(e):
|
|
1290
1340
|
raise
|
|
1291
1341
|
|
|
1292
|
-
return run_sync(cls.delete_where(**filters))
|
|
1342
|
+
return run_sync(cls.delete_where(expected_version=expected_version, **filters))
|
|
1293
1343
|
|
|
1294
1344
|
@classmethod
|
|
1295
1345
|
async def all(cls, *, use_cache: bool = True) -> list[Self]:
|
|
@@ -1373,9 +1423,15 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
|
|
|
1373
1423
|
e.enrich(model=self.__class__, operation="save")
|
|
1374
1424
|
raise
|
|
1375
1425
|
|
|
1376
|
-
async def update(self, **kwargs: Any) -> Self:
|
|
1426
|
+
async def update(self, *, expected_version: int | None = None, **kwargs: Any) -> Self:
|
|
1377
1427
|
"""Update specific fields and save in one operation.
|
|
1378
1428
|
|
|
1429
|
+
``expected_version`` opts into optimistic concurrency: the UPDATE becomes
|
|
1430
|
+
a compare-and-swap on the canonical ``version`` column against the
|
|
1431
|
+
declared base revision, raising OptimisticLockError (with the live row)
|
|
1432
|
+
on a mismatch and DoesNotExist when the row is gone. Omitted → exact
|
|
1433
|
+
legacy behavior.
|
|
1434
|
+
|
|
1379
1435
|
Returns the same instance with updated fields.
|
|
1380
1436
|
"""
|
|
1381
1437
|
invalid_fields = [k for k in kwargs if k not in self._fields]
|
|
@@ -1394,7 +1450,9 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
|
|
|
1394
1450
|
for field, value in kwargs.items():
|
|
1395
1451
|
setattr(self, field, value)
|
|
1396
1452
|
|
|
1397
|
-
await update.update_instance(
|
|
1453
|
+
await update.update_instance(
|
|
1454
|
+
self, fields=kwargs.keys(), expected_version=expected_version
|
|
1455
|
+
)
|
|
1398
1456
|
# Clear ONLY what was actually written. _mark_clean() here would wipe
|
|
1399
1457
|
# unrelated pending edits: `m.purpose = "x"` (dirty, unsaved) followed by
|
|
1400
1458
|
# `await m.update(status="y")` would drop `purpose` from _dirty, and the
|
|
@@ -375,7 +375,9 @@ class BaseManager(Generic[ModelT]):
|
|
|
375
375
|
return initialized_items
|
|
376
376
|
|
|
377
377
|
@handle_errors
|
|
378
|
-
async def _update_item(
|
|
378
|
+
async def _update_item(
|
|
379
|
+
self, item: ModelT, *, expected_version: int | None = None, **updates: Any
|
|
380
|
+
) -> ModelT:
|
|
379
381
|
"""Update an existing item."""
|
|
380
382
|
if not item:
|
|
381
383
|
raise AppError(
|
|
@@ -384,7 +386,7 @@ class BaseManager(Generic[ModelT]):
|
|
|
384
386
|
client_visible="The item to update could not be found.",
|
|
385
387
|
)
|
|
386
388
|
updates = self._validate_input_data(updates, partial=True)
|
|
387
|
-
await item.update(**updates)
|
|
389
|
+
await item.update(expected_version=expected_version, **updates)
|
|
388
390
|
return await self._initialize_item_runtime(item)
|
|
389
391
|
|
|
390
392
|
@handle_errors
|
|
@@ -613,17 +615,22 @@ class BaseManager(Generic[ModelT]):
|
|
|
613
615
|
items_data, batch_size=batch_size, ignore_conflicts=ignore_conflicts
|
|
614
616
|
)
|
|
615
617
|
|
|
616
|
-
async def update_item(
|
|
618
|
+
async def update_item(
|
|
619
|
+
self, item_id: Any, *, expected_version: int | None = None, **updates: Any
|
|
620
|
+
) -> ModelT:
|
|
617
621
|
"""Update an item by primary key.
|
|
618
622
|
|
|
619
623
|
Uses ``self._pk_name`` so models whose primary key is NOT named ``id``
|
|
620
624
|
(e.g. ``FileAnalysis`` whose PK is ``file_id``) work correctly.
|
|
621
625
|
Sibling methods like ``update_item_fields`` and ``remove_active_by_id``
|
|
622
626
|
already use this pattern — ``update_item`` was the historical outlier.
|
|
627
|
+
|
|
628
|
+
``expected_version`` opts into optimistic concurrency (compare-and-swap
|
|
629
|
+
on the canonical ``version`` column) — see ``Model.update``.
|
|
623
630
|
"""
|
|
624
631
|
async with self._governed_write():
|
|
625
632
|
item = await self._get_item_or_raise(**self._pk_filter(item_id))
|
|
626
|
-
return await self._update_item(item, **updates)
|
|
633
|
+
return await self._update_item(item, expected_version=expected_version, **updates)
|
|
627
634
|
|
|
628
635
|
async def update_item_fields(self, item_id: Any, **updates: Any) -> int:
|
|
629
636
|
"""Update an item by ID via a single ``UPDATE … WHERE id=$1`` statement.
|
|
@@ -232,3 +232,83 @@ class MatrxEntity(Model):
|
|
|
232
232
|
# Refresh through the ORM so the short-term cache holds the RESTORED row.
|
|
233
233
|
await cls.get_or_none(id=str(self.id), use_cache=False)
|
|
234
234
|
return int(new_version)
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
async def update_with_rebase(
|
|
238
|
+
cls,
|
|
239
|
+
item_id: Any,
|
|
240
|
+
*,
|
|
241
|
+
expected_version: int,
|
|
242
|
+
max_attempts: int = 3,
|
|
243
|
+
**updates: Any,
|
|
244
|
+
) -> Self:
|
|
245
|
+
"""Optimistically update, auto-rebasing past DISJOINT concurrent writes.
|
|
246
|
+
|
|
247
|
+
The platform conflict policy for field-scoped edits: a version conflict
|
|
248
|
+
where the intervening writes did not touch any of the fields being
|
|
249
|
+
written here is not a real conflict — rebase onto the head revision and
|
|
250
|
+
retry. Decided from the version SNAPSHOTS (``history.row_versions``):
|
|
251
|
+
for each field in ``updates``, compare its value at the caller's base
|
|
252
|
+
revision against its value at the head revision. Any difference means a
|
|
253
|
+
genuine overlapping edit → OptimisticLockError propagates (with
|
|
254
|
+
``current_version`` + ``current_row`` for the caller's own merge UI).
|
|
255
|
+
|
|
256
|
+
Requires the entity to be versioned (snapshots are the evidence);
|
|
257
|
+
a jsonb column counts as ONE field — no intra-JSON merging.
|
|
258
|
+
"""
|
|
259
|
+
from matrx_orm.exceptions import OptimisticLockError
|
|
260
|
+
|
|
261
|
+
cls._require(
|
|
262
|
+
"versioning",
|
|
263
|
+
cls._is_versioned,
|
|
264
|
+
"update_with_rebase needs version snapshots to prove the concurrent "
|
|
265
|
+
"writes were disjoint. Use update_item(expected_version=...) and "
|
|
266
|
+
"handle the conflict yourself.",
|
|
267
|
+
)
|
|
268
|
+
token = cls._require_token()
|
|
269
|
+
log = cls._version_log()
|
|
270
|
+
|
|
271
|
+
base_version = int(expected_version)
|
|
272
|
+
for _ in range(max(1, max_attempts)):
|
|
273
|
+
try:
|
|
274
|
+
return await cls.update_item(
|
|
275
|
+
item_id, expected_version=base_version, **updates
|
|
276
|
+
)
|
|
277
|
+
except OptimisticLockError as exc:
|
|
278
|
+
head_version = exc.current_version
|
|
279
|
+
if head_version is None:
|
|
280
|
+
raise
|
|
281
|
+
# Newest snapshot per version (a DELETE reuses the prior version
|
|
282
|
+
# number, so order by id — the log's own monotonic identity).
|
|
283
|
+
snapshots = (
|
|
284
|
+
await log.filter(
|
|
285
|
+
entity_type=token,
|
|
286
|
+
row_id=str(item_id),
|
|
287
|
+
version__in=[base_version, int(head_version)],
|
|
288
|
+
)
|
|
289
|
+
.order_by("-id")
|
|
290
|
+
.all()
|
|
291
|
+
)
|
|
292
|
+
base = next((s for s in snapshots if s.version == base_version), None)
|
|
293
|
+
head = next((s for s in snapshots if s.version == int(head_version)), None)
|
|
294
|
+
if base is None or head is None:
|
|
295
|
+
raise # no evidence → never guess; surface the conflict
|
|
296
|
+
overlapping = [
|
|
297
|
+
f
|
|
298
|
+
for f in updates
|
|
299
|
+
if (base.row_data or {}).get(f) != (head.row_data or {}).get(f)
|
|
300
|
+
]
|
|
301
|
+
if overlapping:
|
|
302
|
+
raise # a field we are writing really changed underneath us
|
|
303
|
+
base_version = int(head_version)
|
|
304
|
+
# Retries exhausted under sustained contention — surface the live state.
|
|
305
|
+
raise OptimisticLockError(
|
|
306
|
+
model=cls,
|
|
307
|
+
pk=item_id,
|
|
308
|
+
expected_version=base_version,
|
|
309
|
+
message=(
|
|
310
|
+
f"update_with_rebase gave up after {max_attempts} attempts on "
|
|
311
|
+
f"{cls.__name__} {item_id} — sustained concurrent writes. "
|
|
312
|
+
f"Re-fetch and retry."
|
|
313
|
+
),
|
|
314
|
+
)
|
|
@@ -1912,17 +1912,30 @@ class OptimisticLockError(ORMException):
|
|
|
1912
1912
|
pk=None,
|
|
1913
1913
|
expected_version: int | None = None,
|
|
1914
1914
|
message: str | None = None,
|
|
1915
|
+
current_version: int | None = None,
|
|
1916
|
+
current_row=None,
|
|
1915
1917
|
) -> None:
|
|
1916
1918
|
msg = message or (
|
|
1917
1919
|
f"Optimistic lock conflict on {getattr(model, '__name__', model)} "
|
|
1918
|
-
f"(pk={pk}, expected version={expected_version}
|
|
1919
|
-
"
|
|
1920
|
+
f"(pk={pk}, expected version={expected_version}"
|
|
1921
|
+
+ (f", current version={current_version}" if current_version is not None else "")
|
|
1922
|
+
+ "). The record was modified by another process — re-fetch and retry."
|
|
1920
1923
|
)
|
|
1921
1924
|
super().__init__(
|
|
1922
1925
|
message=msg,
|
|
1923
1926
|
model=model,
|
|
1924
|
-
details={
|
|
1927
|
+
details={
|
|
1928
|
+
"pk": pk,
|
|
1929
|
+
"expected_version": expected_version,
|
|
1930
|
+
"current_version": current_version,
|
|
1931
|
+
},
|
|
1925
1932
|
)
|
|
1933
|
+
self.expected_version = expected_version
|
|
1934
|
+
self.current_version = current_version
|
|
1935
|
+
# The live row as re-read at conflict time (a Model instance), so the
|
|
1936
|
+
# caller can merge/rebase without a second fetch. May be None when the
|
|
1937
|
+
# raiser could not re-read.
|
|
1938
|
+
self.current_row = current_row
|
|
1926
1939
|
|
|
1927
1940
|
|
|
1928
1941
|
class WriteQueueFullError(DatabaseError):
|
|
@@ -22,14 +22,124 @@ async def update(model_cls: type[Model], filters: dict[str, Any], **kwargs: Any)
|
|
|
22
22
|
return await QueryBuilder(model_cls).filter(**filters).update(**kwargs)
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
# The canonical revision column of the platform entity contract. `platform._touch_row`
|
|
26
|
+
# bumps it on every UPDATE; optimistic concurrency compares-and-swaps against it.
|
|
27
|
+
VERSION_COLUMN = "version"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _require_version_column(model_cls: type[Model], version_column: str) -> None:
|
|
31
|
+
if version_column not in model_cls._fields:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"{model_cls.__name__} has no '{version_column}' column — optimistic "
|
|
34
|
+
f"concurrency (expected_version=) requires the canonical integer "
|
|
35
|
+
f"version column maintained by platform._touch_row. Either the table "
|
|
36
|
+
f"is not a canonical entity, or the model is stale (rerun generation)."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _raise_stale_write(
|
|
41
|
+
model_cls: type[Model],
|
|
42
|
+
pk_filter: dict[str, Any],
|
|
43
|
+
version_column: str,
|
|
44
|
+
expected_version: int | None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Classify a guarded UPDATE that hit 0 rows: gone vs. changed.
|
|
47
|
+
|
|
48
|
+
Re-reads by primary key alone (no version filter, no cache). Row absent →
|
|
49
|
+
DoesNotExist (deleted concurrently, or never existed). Row present → its
|
|
50
|
+
version no longer matches → OptimisticLockError carrying the live row.
|
|
51
|
+
"""
|
|
52
|
+
from matrx_orm.exceptions import DoesNotExist, OptimisticLockError
|
|
53
|
+
|
|
54
|
+
# Note: inside an open Session the re-read merges PENDING (uncommitted)
|
|
55
|
+
# writes for this row — a CAS should run on the direct write paths, not
|
|
56
|
+
# Coordinator-adjacent code (see the contract's session-plane exclusion).
|
|
57
|
+
current = await QueryBuilder(model_cls).filter(**pk_filter).get_or_none()
|
|
58
|
+
pk_value = pk_filter if len(pk_filter) > 1 else next(iter(pk_filter.values()))
|
|
59
|
+
if current is None or getattr(current, "deleted_at", None) is not None:
|
|
60
|
+
# Hard-gone or soft-deleted: for conflict classification a soft-deleted
|
|
61
|
+
# row IS gone — reporting it as "changed by someone else, refresh and
|
|
62
|
+
# re-apply" would send the user chasing a record that no longer exists.
|
|
63
|
+
raise DoesNotExist(model=model_cls, filters=pk_filter)
|
|
64
|
+
raise OptimisticLockError(
|
|
65
|
+
model=model_cls,
|
|
66
|
+
pk=pk_value,
|
|
67
|
+
expected_version=expected_version,
|
|
68
|
+
current_version=getattr(current, version_column, None),
|
|
69
|
+
current_row=current,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def guarded_update(
|
|
74
|
+
model_cls: type[Model],
|
|
75
|
+
filters: dict[str, Any],
|
|
76
|
+
update_data: dict[str, Any],
|
|
77
|
+
*,
|
|
78
|
+
expected_version: int,
|
|
79
|
+
version_column: str = VERSION_COLUMN,
|
|
80
|
+
) -> UpdateResult:
|
|
81
|
+
"""Compare-and-swap UPDATE: WHERE <filters> AND version = expected_version.
|
|
82
|
+
|
|
83
|
+
``filters`` must address one row (every primary-key column as a plain
|
|
84
|
+
equality; extra narrowing filters are allowed). The version column is also
|
|
85
|
+
SET to expected_version + 1 so the CAS observes GUARDED writes on tables
|
|
86
|
+
without the `_touch_row` trigger; where the trigger exists it recomputes
|
|
87
|
+
the same value (OLD.version + 1) and wins — identical result. NOTE: on
|
|
88
|
+
trigger-less tables the CAS is sound only among writers that bump the
|
|
89
|
+
version column — an unguarded UPDATE that leaves `version` untouched is
|
|
90
|
+
invisible to it. Every content-write path on such a table must bump
|
|
91
|
+
(e.g. `version=F("version") + 1`), as matrx-graph's DefinitionStore does.
|
|
92
|
+
|
|
93
|
+
0 rows affected raises: DoesNotExist when the row is gone,
|
|
94
|
+
OptimisticLockError (with current_version + current_row) when it changed.
|
|
95
|
+
The gone-vs-changed re-read uses the PRIMARY-KEY columns only, so an extra
|
|
96
|
+
narrowing filter that stopped matching still classifies as a conflict.
|
|
97
|
+
"""
|
|
98
|
+
_require_version_column(model_cls, version_column)
|
|
99
|
+
data = dict(update_data)
|
|
100
|
+
data.setdefault(version_column, expected_version + 1)
|
|
101
|
+
result = await update(model_cls, {**filters, version_column: expected_version}, **data)
|
|
102
|
+
if result.rows_affected == 0:
|
|
103
|
+
pk_filter = {pk: filters[pk] for pk in model_cls._meta.primary_keys}
|
|
104
|
+
await _raise_stale_write(model_cls, pk_filter, version_column, expected_version)
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def bulk_update(
|
|
109
|
+
model_cls: type[Model],
|
|
110
|
+
objects: Sequence[Model],
|
|
111
|
+
fields: list[str],
|
|
112
|
+
*,
|
|
113
|
+
check_version: bool = False,
|
|
114
|
+
) -> int:
|
|
26
115
|
"""
|
|
27
116
|
Enhanced bulk_update that works within the current ORM limitations.
|
|
28
117
|
Uses controlled batch processing for individual per-row updates.
|
|
118
|
+
|
|
119
|
+
``check_version=True`` turns each row's write into a compare-and-swap on the
|
|
120
|
+
canonical ``version`` column, using the version each instance was LOADED
|
|
121
|
+
with. Rows whose stored version moved are counted as failures (reported
|
|
122
|
+
loudly in the summary), never silently overwritten and never raised to the
|
|
123
|
+
caller — compare the returned count to ``len(objects)`` to detect losses.
|
|
124
|
+
Every instance must carry a loaded ``version`` value; a batch containing a
|
|
125
|
+
version-less stub raises ValueError BEFORE any row is written.
|
|
29
126
|
"""
|
|
30
127
|
if not objects or not fields:
|
|
31
128
|
return 0
|
|
32
129
|
|
|
130
|
+
if check_version:
|
|
131
|
+
_require_version_column(model_cls, VERSION_COLUMN)
|
|
132
|
+
stubs = [
|
|
133
|
+
obj for obj in objects if getattr(obj, VERSION_COLUMN, None) is None
|
|
134
|
+
]
|
|
135
|
+
if stubs:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"bulk_update(check_version=True) on {model_cls.__name__}: "
|
|
138
|
+
f"{len(stubs)} instance(s) have no loaded '{VERSION_COLUMN}' "
|
|
139
|
+
f"(partial stubs?). Load rows with their version, or drop the "
|
|
140
|
+
f"guard. No rows were written."
|
|
141
|
+
)
|
|
142
|
+
|
|
33
143
|
invalid_fields = [k for k in fields if k not in model_cls._fields]
|
|
34
144
|
if invalid_fields:
|
|
35
145
|
vcprint(model_cls._fields, "Model Fields", color="yellow")
|
|
@@ -60,8 +170,21 @@ async def bulk_update(model_cls: type[Model], objects: Sequence[Model], fields:
|
|
|
60
170
|
if value is not None:
|
|
61
171
|
update_data[field_name] = field.get_db_prep_value(value)
|
|
62
172
|
|
|
173
|
+
row_filter = dict(pk_filter)
|
|
174
|
+
if check_version:
|
|
175
|
+
base_version = int(getattr(obj, VERSION_COLUMN))
|
|
176
|
+
row_filter[VERSION_COLUMN] = base_version
|
|
177
|
+
# Unconditional: even if 'version' is in `fields`, the guarded
|
|
178
|
+
# write owns the bump — keeping a caller-supplied base value
|
|
179
|
+
# would let a same-base CAS keep passing on trigger-less tables.
|
|
180
|
+
update_data[VERSION_COLUMN] = base_version + 1
|
|
181
|
+
|
|
63
182
|
if update_data:
|
|
64
|
-
result = await QueryBuilder(model_cls).filter(**
|
|
183
|
+
result = await QueryBuilder(model_cls).filter(**row_filter).update(**update_data)
|
|
184
|
+
if check_version and result.rows_affected == 0:
|
|
185
|
+
await _raise_stale_write(
|
|
186
|
+
model_cls, pk_filter, VERSION_COLUMN, getattr(obj, VERSION_COLUMN, None)
|
|
187
|
+
)
|
|
65
188
|
if result.rows_affected > 0:
|
|
66
189
|
rows_affected += 1
|
|
67
190
|
# Do NOT cache `obj` here. It is the CALLER's instance and is
|
|
@@ -133,10 +256,52 @@ async def decrement(model_cls: type[Model], filters: dict[str, Any], **kwargs: A
|
|
|
133
256
|
return await update(model_cls, filters, **updates)
|
|
134
257
|
|
|
135
258
|
|
|
136
|
-
async def
|
|
137
|
-
|
|
259
|
+
async def guarded_delete(
|
|
260
|
+
model_cls: type[Model],
|
|
261
|
+
filters: dict[str, Any],
|
|
262
|
+
*,
|
|
263
|
+
expected_version: int,
|
|
264
|
+
version_column: str = VERSION_COLUMN,
|
|
265
|
+
) -> int:
|
|
266
|
+
"""Compare-and-swap DELETE: WHERE <filters> AND version = expected_version.
|
|
267
|
+
|
|
268
|
+
``filters`` must address one row (every primary-key column as a plain
|
|
269
|
+
equality). 0 rows deleted: row already gone → returns 0 (a delete is
|
|
270
|
+
idempotent — the desired end state holds); row present with a different
|
|
271
|
+
version → OptimisticLockError (someone rewrote what the caller decided to
|
|
272
|
+
delete; deleting it anyway would discard their work unseen).
|
|
273
|
+
"""
|
|
138
274
|
from matrx_orm.exceptions import OptimisticLockError
|
|
139
275
|
|
|
276
|
+
_require_version_column(model_cls, version_column)
|
|
277
|
+
deleted = (
|
|
278
|
+
await QueryBuilder(model_cls)
|
|
279
|
+
.filter(**{**filters, version_column: expected_version})
|
|
280
|
+
.delete()
|
|
281
|
+
)
|
|
282
|
+
if deleted == 0:
|
|
283
|
+
pk_filter = {pk: filters[pk] for pk in model_cls._meta.primary_keys}
|
|
284
|
+
current = await QueryBuilder(model_cls).filter(**pk_filter).get_or_none()
|
|
285
|
+
# Soft-deleted counts as already gone — idempotent 0, not a conflict.
|
|
286
|
+
if current is not None and getattr(current, "deleted_at", None) is None:
|
|
287
|
+
raise OptimisticLockError(
|
|
288
|
+
model=model_cls,
|
|
289
|
+
pk=pk_filter if len(pk_filter) > 1 else next(iter(pk_filter.values())),
|
|
290
|
+
expected_version=expected_version,
|
|
291
|
+
current_version=getattr(current, version_column, None),
|
|
292
|
+
current_row=current,
|
|
293
|
+
)
|
|
294
|
+
return deleted
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
async def update_instance(
|
|
298
|
+
instance: Model,
|
|
299
|
+
fields: Iterable[str] | None = None,
|
|
300
|
+
*,
|
|
301
|
+
expected_version: int | None = None,
|
|
302
|
+
) -> Model:
|
|
303
|
+
from matrx_orm.core.signals import post_save, pre_save
|
|
304
|
+
|
|
140
305
|
model_cls = instance.__class__
|
|
141
306
|
await pre_save.send(model_cls, instance=instance)
|
|
142
307
|
|
|
@@ -163,6 +328,16 @@ async def update_instance(instance: Model, fields: Iterable[str] | None = None)
|
|
|
163
328
|
current_version = getattr(instance, fname, None)
|
|
164
329
|
break
|
|
165
330
|
|
|
331
|
+
# Explicit caller-declared base revision (the platform optimistic-concurrency
|
|
332
|
+
# contract). Targets the canonical `version` column unless the model declares
|
|
333
|
+
# a VersionField under another name; the explicit value always wins over the
|
|
334
|
+
# instance's in-memory copy.
|
|
335
|
+
if expected_version is not None:
|
|
336
|
+
if version_field_name is None:
|
|
337
|
+
_require_version_column(model_cls, VERSION_COLUMN)
|
|
338
|
+
version_field_name = VERSION_COLUMN
|
|
339
|
+
current_version = expected_version
|
|
340
|
+
|
|
166
341
|
update_data: dict[str, Any] = {}
|
|
167
342
|
field_names: Iterable[str] = (
|
|
168
343
|
fields if fields is not None else [f for f in model_cls._fields if f not in pk_filter]
|
|
@@ -201,12 +376,9 @@ async def update_instance(instance: Model, fields: Iterable[str] | None = None)
|
|
|
201
376
|
result = await update(model_cls, filters, **update_data)
|
|
202
377
|
|
|
203
378
|
if result.rows_affected == 0:
|
|
204
|
-
if version_field_name is not None:
|
|
205
|
-
raise
|
|
206
|
-
|
|
207
|
-
pk=pk_filter if len(pk_filter) > 1 else next(iter(pk_filter.values())),
|
|
208
|
-
expected_version=current_version,
|
|
209
|
-
)
|
|
379
|
+
if version_field_name is not None and current_version is not None:
|
|
380
|
+
# Guarded write: classify gone-vs-changed and raise accordingly.
|
|
381
|
+
await _raise_stale_write(model_cls, pk_filter, version_field_name, current_version)
|
|
210
382
|
raise ValueError(f"No rows were updated for {model_cls.__name__} with {pk_filter}")
|
|
211
383
|
|
|
212
384
|
if result.updated_rows:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "matrx-orm"
|
|
3
|
-
version = "3.1.
|
|
3
|
+
version = "3.1.52"
|
|
4
4
|
description = "Async-first PostgreSQL ORM with bidirectional migrations, schema introspection, many-to-many relationships, and built-in state caching"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|