matrx-orm 3.1.32__tar.gz → 3.1.34__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.32 → matrx_orm-3.1.34}/CLAUDE.md +2 -1
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/PKG-INFO +1 -1
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/associations.py +14 -1
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/operations/conflict_writes.py +14 -1
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/operations/create.py +53 -2
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/pyproject.toml +1 -1
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_associations.py +41 -2
- matrx_orm-3.1.34/tests/level1/test_upsert_default_leak.py +307 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/.gitignore +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/AGENTS.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/BASE_CLASS_METHODS.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/MODEL_API.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/README.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/database/orm/extended/managers/ai_model_base.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/docs/migrations.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/MODULE_README.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/adapters/MODULE_README.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/adapters/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/adapters/async_postgresql.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/adapters/base_adapter.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/adapters/postgresql.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/adapters/postgrest_client_adapter.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/adapters/supabase_adapter.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/admin/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/admin/router.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/api/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/api/auth.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/api/config.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/api/handlers.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/api/protocol.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/api/server.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/cache_debug.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/catalog.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/client/MODULE_README.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/client/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/client/postgres_connection.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/client/postgrest.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/client/supabase_auth.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/client/supabase_config.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/client/supabase_manager.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/EXTENDED-TASK.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/MODULE_README.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/RELATIONS-TASKS.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/TASKS.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/async_db_manager.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/base.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/config.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/connections.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/db_function.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/diagnostics.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/expressions.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/extended.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/fields.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/introspection.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/listen.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/loop_filters.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/model_dto.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/model_view.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/paginator.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/pool_maintenance.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/pydantic_bridge.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/registry.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/relations.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/resilience.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/rls_session.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/signals.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/tls.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/transaction.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/types.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/core/write_queue.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/entity.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/error_handling.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/exceptions.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/extended/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/extended/app_error_handler.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/identity_rule.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/middleware/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/middleware/base.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/migrations/MODULE_README.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/migrations/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/migrations/cli.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/migrations/ddl.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/migrations/diff.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/migrations/executor.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/migrations/integration.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/migrations/loader.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/migrations/operations.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/migrations/state.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/migrations/table_filter.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/operations/MODULE_README.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/operations/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/operations/bulk_update_values.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/operations/conflict.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/operations/db_functions.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/operations/delete.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/operations/dynamic_admin.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/operations/dynamic_crud.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/operations/graph_walk.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/operations/latest_rows.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/operations/queue_claim.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/operations/read.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/operations/relational_backfill.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/operations/staging_load.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/operations/update.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/py.typed +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/python_sql/MODULE_README.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/python_sql/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/python_sql/db_objects.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/python_sql/table_detailed_relationships.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/python_sql/table_typescript_relationship.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/query/MODULE_README.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/query/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/query/builder.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/query/executor.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/MODULE_README.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/code_handler.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/codegen_writer.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/columns.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/common.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/diff_preview.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/entity_capabilities.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/generator.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/helpers/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/helpers/base_generators.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/helpers/entity_generators.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/helpers/git_checker.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/package_wiring.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/relationships.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/runner.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/schema.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/schema_manager.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/tables.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/schema_builder/views.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/secrets_battery/FEATURE.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/secrets_battery/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/secrets_battery/crypto.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/secrets_battery/items.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/secrets_battery/model.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/secrets_battery/org_service.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/secrets_battery/service.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/session/README.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/session/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/session/coalesce.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/session/dag.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/session/errors.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/session/fallback.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/session/flush.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/session/lifecycle.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/session/managed.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/session/op.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/session/reads.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/session/session.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/session/telemetry.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/sql_executor/MODULE_README.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/sql_executor/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/sql_executor/executor.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/sql_executor/queries.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/sql_executor/registry.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/sql_executor/types.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/sql_executor/utils.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/state.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/utils/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/utils/sql_utils.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/matrx_orm/utils/type_converters.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/scripts/git-branches.sh +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/scripts/publish.sh +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/scripts/release.sh +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/MODULE_README.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/conftest.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/MODULE_README.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_admin_db_columns.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_admin_search_negation.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_admin_search_sentinels.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_advisory_lock.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_agent_message.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_api_auth.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_api_config.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_api_handlers.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_api_protocol.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_array_agg_order_by.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_bulk_update_by_pk.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_bulk_upsert_increment_set_fields.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_cache_debug.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_call_function.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_case_when.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_catalog.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_composite_pk_filter.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_composite_pk_write_paths.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_config.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_conflict_target.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_conflict_writes.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_connection_codecs.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_connection_poison_guard.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_connection_primitives.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_count_composite_distinct.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_db_function_field.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_ddl_generator.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_derived_table.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_dirty_tracking.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_dynamic_crud.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_exceptions.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_expression_primitives.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_fields.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_filter_jsonb_agg_array_index.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_from_alias.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_fts.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_func_expression_args.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_graph_walk.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_having_annotate_alias.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_insert_ignore_and_admin_primitives.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_introspect_rls_policies.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_join_and_aggregate.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_latest_rows.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_listen_notify.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_managed_write_guard.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_matrx_entity.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_migration_diff_types.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_migration_loader.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_model_instance.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_model_meta.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_order_by_expression.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_patch_jsonb_path.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_pool_maintenance.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_postgrest_filters.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_pydantic_bridge.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_query_builder.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_query_error_detail.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_query_executor_sql.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_query_timeout.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_querybuilder_clone_semantics.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_queue_claim.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_registry.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_registry_multi_database.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_registry_multi_schema.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_relational_backfill.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_relations.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_resilience.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_schema_exists.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_session_advisory_lock.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_state_cache.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_subquery_filter_raw.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_subquery_in_filter.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_supabase_auth.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_supabase_config.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_tls.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_typed_join_projection.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_typed_predicates.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_update_case_expression.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_update_subquery.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_upsert_with_conflict.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_values_jsonb_decode.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_watched_lifecycle.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level1/test_write_retry_semantics.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level2/MODULE_README.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level2/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level2/conftest.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level2/test_bulk_ops.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level2/test_cache_integration.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level2/test_crud.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level2/test_foreign_keys.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level2/test_m2m.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level2/test_manager.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level2/test_migrations_live.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level2/test_query_execution.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/level2/test_schema_diff.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/sample_project/.env.example +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/sample_project/README.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/sample_project/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/sample_project/generate.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/sample_project/generated/.gitkeep +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/sample_project/matrx_orm.yaml +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/sample_project/test_schema_generation.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/sample_project_desktop/.env.example +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/sample_project_desktop/README.md +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/sample_project_desktop/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/sample_project_desktop/client_example.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/sample_project_desktop/client_example.ts +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/sample_project_desktop/client_supabase_example.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/sample_project_desktop/server.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/schema/entity_tests.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/schema/test_base_generation.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/schema/test_composite_pk_fk_generation.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/schema/test_composite_unique_generation.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/schema/test_dto_identity_generation.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/schema/test_entity_capability_generation.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/schema/test_generate_schema.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/schema/test_junction_analysis.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/schema/test_multi_schema_output.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/schema/test_sql_expression_defaults.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/schema/test_view_generation.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/schema_builder/test_focused_generation_plan.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/session/__init__.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/session/conftest.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/session/test_capture_poison_proof.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/session/test_coalesce.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/session/test_dag.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/session/test_disk_spill_fallback.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/session/test_flush_individual_fallback.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/session/test_governed_write.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/session/test_managed.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/session/test_op_enqueue_site.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/session/test_reads.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/session/test_session.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/test_database_name_alias.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/test_db_functions.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/test_model_cls_refactor.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/test_no_blocking_asyncpg_tls.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/test_secrets_battery.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/test_sql_param_casts.py +0 -0
- {matrx_orm-3.1.32 → matrx_orm-3.1.34}/tests/test_sync_twins_guard.py +0 -0
|
@@ -23,6 +23,7 @@ Every table that becomes a Model needs row identity: a single-column primary key
|
|
|
23
23
|
- **Core model layer**: `Model`, `BaseManager`, `BaseDTO`, `ModelView`, `model_registry`, plus 50+ field types (`CharField`, `IntegerField`, `ForeignKey`, `ManyToManyField`, `JSONField`, …).
|
|
24
24
|
- **`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`).
|
|
25
25
|
- **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.
|
|
26
|
+
- **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`.
|
|
26
27
|
- **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).
|
|
27
28
|
- **Connection-level type codecs** (`async_db_manager._init_connection_default`): every pooled connection decodes **uuid → `str`**, **vector → `list[float]`**, and **json/jsonb → `dict`/`list`** at the driver boundary — the TYPE decides the decode, never the call path. So `call_function` scalar/rows, raw fetches, and field hydration all agree; a caller must **never `json.loads` a fetched json/jsonb value** (it's already decoded; the 2026-07-13 `build_agent_context` crash was a jsonb RPC fetched pre-codec as a raw string). Encoding: a pre-serialised JSON `str` passes through untouched; a native dict/list is dumped (`_encode_json`). Pinned by `tests/level1/test_connection_codecs.py`.
|
|
28
29
|
- **Query layer**: `QueryBuilder`, typed predicates/projections (`F`, `Q`,
|
|
@@ -37,7 +38,7 @@ Every table that becomes a Model needs row identity: a single-column primary key
|
|
|
37
38
|
- **Signals**: `pre_create`, `post_create`, `pre_save`, `post_save`, `pre_delete`, `post_delete`.
|
|
38
39
|
- **Schema builder** (`matrx_orm.schema_builder`): code generation for Python + TypeScript type definitions from the DB schema. Postgres **VIEWs** are emitted as **read-only `Model` classes** into the same schema models file (on by default; per-entry `output.include_views: false` opts a schema out, `output.exclude_views: [...]` skips individual views). View models get no manager/DTO/base-class/auto_config (views are not writable), synthesize `_primary_keys` from an `id` column (else the first column, for cache-key/`load_by_id` only), and emit plain typed fields with **no `ForeignKey`** (views carry no FK metadata — nothing cross-schema to resolve). Never hand-write a view model again.
|
|
39
40
|
- **Secrets battery** (`matrx_orm.secrets_battery`): the ONE canonical user-credential vault — `users.user_secrets` CRUD + the platform's single Fernet primitive, keyed by `CREDENTIALS_ENCRYPTION_KEY`. Any package: `configure_secrets(db_config_name=...)` once, then `await require_user_secret(user_id, key)`. No package ever calls another service to resolve a credential. Contract: [`matrx_orm/secrets_battery/FEATURE.md`](matrx_orm/secrets_battery/FEATURE.md).
|
|
40
|
-
- **Polymorphic associations** (`matrx_orm.associations`): `EntityRegistry` (token→Model) + `Associations` — a registry-driven, **ownership-gated** edge engine over a host's polymorphic edge table. Every `source→target` hop is org-scoped *and* the resolved target's `org_id` is re-verified (fail-closed, screams on mismatch), so a hop can't widen access past the tenant boundary. Hardcodes no table names — the host wires its own edge + token-registry models.
|
|
41
|
+
- **Polymorphic associations** (`matrx_orm.associations`): `EntityRegistry` (token→Model) + `Associations` — a registry-driven, **ownership-gated** edge engine over a host's polymorphic edge table. Every `source→target` hop is org-scoped *and* the resolved target's `org_id` is re-verified (fail-closed, screams on mismatch), so a hop can't widen access past the tenant boundary. **System vocabulary is exempt from the org-equality check** (existence still required): a model generated with `_rls_variant="system"` is platform-shared dictionary data (seo.keyword/topic) every tenant legitimately links to; the edge still carries the caller's org. Hardcodes no table names — the host wires its own edge + token-registry models.
|
|
41
42
|
|
|
42
43
|
Public API is defined explicitly in `matrx_orm/__init__.py` (100+ exports). Treat changes there as semver-breaking.
|
|
43
44
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: matrx-orm
|
|
3
|
-
Version: 3.1.
|
|
3
|
+
Version: 3.1.34
|
|
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
|
|
@@ -400,12 +400,22 @@ class Associations:
|
|
|
400
400
|
return await self._load_gated(source_type, ids, org_id)
|
|
401
401
|
|
|
402
402
|
async def _verify_in_org(self, model: type[Model], ref: EntityRef, org_id: str) -> None:
|
|
403
|
-
"""Write-side gate: the endpoint must exist (RLS-visible) and be in ``org_id``.
|
|
403
|
+
"""Write-side gate: the endpoint must exist (RLS-visible) and be in ``org_id``.
|
|
404
|
+
|
|
405
|
+
**System vocabulary is exempt from the org-equality check** (existence
|
|
406
|
+
is still required): a model generated with ``_rls_variant == "system"``
|
|
407
|
+
is platform-shared dictionary data (e.g. seo.keyword / seo.topic) that
|
|
408
|
+
every tenant legitimately links to — its rows live in the system org by
|
|
409
|
+
design, so org-equality would refuse every registered edge to it. The
|
|
410
|
+
edge row itself still carries the CALLER's org (the tenancy boundary).
|
|
411
|
+
"""
|
|
404
412
|
entity = await model.load_by_id_or_none(ref.id)
|
|
405
413
|
if entity is None:
|
|
406
414
|
raise CrossOrgAssociationError(
|
|
407
415
|
f"cannot associate {ref.type}:{ref.id} — not found or not accessible in org {org_id}"
|
|
408
416
|
)
|
|
417
|
+
if getattr(model, "_rls_variant", None) == "system": # orm-getattr-ok: generator-stamped capability flag
|
|
418
|
+
return
|
|
409
419
|
entity_org = getattr(entity, self._org, None)
|
|
410
420
|
if entity_org is not None and str(entity_org) != str(org_id):
|
|
411
421
|
raise CrossOrgAssociationError(
|
|
@@ -419,6 +429,9 @@ class Associations:
|
|
|
419
429
|
entity = await model.load_by_id_or_none(rid)
|
|
420
430
|
if entity is None:
|
|
421
431
|
continue # layer 1: RLS-filtered, soft-deleted, or absent
|
|
432
|
+
if getattr(model, "_rls_variant", None) == "system": # orm-getattr-ok: generator-stamped capability flag
|
|
433
|
+
out.append(entity) # system vocabulary: platform-shared, org check N/A
|
|
434
|
+
continue
|
|
422
435
|
entity_org = getattr(entity, self._org, None)
|
|
423
436
|
if entity_org is not None and str(entity_org) != str(org_id):
|
|
424
437
|
# layer 2 fired => layer 1 / RLS already failed. Scream.
|
|
@@ -460,8 +460,21 @@ async def upsert_with_conflict(
|
|
|
460
460
|
placeholders = [f"${i + 1}" for i in range(len(values))]
|
|
461
461
|
|
|
462
462
|
resolved_set_fields: dict[str, Any] = dict(set_fields or {})
|
|
463
|
+
# ONLY columns the CALLER supplied — `prepared` also carries materialized
|
|
464
|
+
# model defaults, which belong in the INSERT half but must never reach
|
|
465
|
+
# DO UPDATE SET (they silently reset real state on every re-upsert of an
|
|
466
|
+
# existing row). This matches the documented contract above: "Any column in
|
|
467
|
+
# `data` NOT given an explicit entry defaults to Excluded(col)".
|
|
463
468
|
for col in columns:
|
|
464
|
-
|
|
469
|
+
if col in data:
|
|
470
|
+
resolved_set_fields.setdefault(col, Excluded(col))
|
|
471
|
+
if not resolved_set_fields:
|
|
472
|
+
# The caller supplied nothing but conflict keys and no set_fields —
|
|
473
|
+
# exactly one meaning ("ensure this row exists"), so reconcile to a
|
|
474
|
+
# no-op self-assignment rather than emitting an empty SET clause. A
|
|
475
|
+
# bare DO NOTHING is wrong here: it would return no row, breaking the
|
|
476
|
+
# RETURNING * contract this function promises.
|
|
477
|
+
resolved_set_fields[columns[0]] = Excluded(columns[0])
|
|
465
478
|
for col in resolved_set_fields:
|
|
466
479
|
if not isinstance(col, str) or not _IDENT_RE.match(col):
|
|
467
480
|
raise ValueError(f"set_fields column must be a plain identifier; got {col!r}")
|
|
@@ -38,6 +38,40 @@ def _normalise_on_conflict(on_conflict: OnConflictArg) -> tuple[list[str], Confl
|
|
|
38
38
|
)
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
def _conflict_update_fields(
|
|
42
|
+
model_cls: type[Model],
|
|
43
|
+
caller_keys: set[str],
|
|
44
|
+
conflict_fields: list[str],
|
|
45
|
+
) -> list[str]:
|
|
46
|
+
"""Which columns an upsert's ``ON CONFLICT DO UPDATE SET`` may touch.
|
|
47
|
+
|
|
48
|
+
ONLY the columns the caller actually supplied. The INSERT half of an upsert
|
|
49
|
+
legitimately materializes every model default (a brand-new row must get
|
|
50
|
+
them), but letting those defaults reach the UPDATE half silently RESETS real
|
|
51
|
+
state on every re-upsert of an existing row — no error, no log.
|
|
52
|
+
|
|
53
|
+
That is not hypothetical: `research/search.py` upserts search hits while
|
|
54
|
+
deliberately omitting `scrape_status` / `server_attempts` / `is_included`,
|
|
55
|
+
expecting DO UPDATE to leave them alone. Instead every re-discovery of a
|
|
56
|
+
known URL reset a scraped, analyzed source back to 'pending'/0, and a
|
|
57
|
+
user's manual "exclude" was silently undone. `save()` already had this
|
|
58
|
+
discipline (it writes only loaded/changed columns); `upsert()` never did.
|
|
59
|
+
|
|
60
|
+
When the caller supplied nothing BUT conflict keys there is exactly one
|
|
61
|
+
thing they can mean — "ensure this row exists" — so reconcile to a no-op
|
|
62
|
+
self-assignment rather than raising. It keeps `RETURNING *` returning the
|
|
63
|
+
row, which a bare `DO NOTHING` would not.
|
|
64
|
+
"""
|
|
65
|
+
updatable = [
|
|
66
|
+
name
|
|
67
|
+
for name in caller_keys
|
|
68
|
+
if isinstance(model_cls._fields.get(name), Field) and name not in conflict_fields
|
|
69
|
+
]
|
|
70
|
+
if updatable:
|
|
71
|
+
return sorted(updatable)
|
|
72
|
+
return [conflict_fields[0]] if conflict_fields else []
|
|
73
|
+
|
|
74
|
+
|
|
41
75
|
async def create(model_cls: type[Model], **kwargs: Any) -> Model:
|
|
42
76
|
instance = model_cls(**kwargs)
|
|
43
77
|
return await save(instance)
|
|
@@ -165,7 +199,17 @@ async def upsert(
|
|
|
165
199
|
query["conflict_fields"] = conflict_fields
|
|
166
200
|
if target is not None:
|
|
167
201
|
query["conflict_target"] = target
|
|
168
|
-
|
|
202
|
+
# `prepared` carries materialized defaults so the INSERT half is complete;
|
|
203
|
+
# the UPDATE half must see ONLY what the caller supplied. See
|
|
204
|
+
# _conflict_update_fields.
|
|
205
|
+
query["update_fields"] = update_fields or _conflict_update_fields(
|
|
206
|
+
model_cls,
|
|
207
|
+
# Intersect with `prepared`: a caller-supplied None is already dropped
|
|
208
|
+
# from the INSERT, and `EXCLUDED.<col>` on a column absent from the
|
|
209
|
+
# INSERT list resolves to that column's DEFAULT — reintroducing the bug.
|
|
210
|
+
{key for key in data if key in prepared},
|
|
211
|
+
conflict_fields,
|
|
212
|
+
)
|
|
169
213
|
|
|
170
214
|
executor = QueryExecutor(query)
|
|
171
215
|
result = await executor.upsert(query)
|
|
@@ -198,6 +242,7 @@ async def bulk_upsert(
|
|
|
198
242
|
conflict_fields, target = _normalise_on_conflict(on_conflict)
|
|
199
243
|
|
|
200
244
|
data_list: list[dict[str, Any]] = []
|
|
245
|
+
supplied_keys: set[str] = set()
|
|
201
246
|
for obj_data in objects_data:
|
|
202
247
|
instance = model_cls(**obj_data)
|
|
203
248
|
prepared: dict[str, Any] = {}
|
|
@@ -209,13 +254,19 @@ async def bulk_upsert(
|
|
|
209
254
|
if value is not None:
|
|
210
255
|
prepared[field_name] = field.get_db_prep_value(value)
|
|
211
256
|
data_list.append(prepared)
|
|
257
|
+
supplied_keys.update(key for key in obj_data if key in prepared)
|
|
212
258
|
|
|
213
259
|
query = QueryBuilder(model_cls)._build_query()
|
|
214
260
|
query["data"] = data_list
|
|
215
261
|
query["conflict_fields"] = conflict_fields
|
|
216
262
|
if target is not None:
|
|
217
263
|
query["conflict_target"] = target
|
|
218
|
-
|
|
264
|
+
# Union of the keys the caller actually supplied across all rows — a column
|
|
265
|
+
# nobody supplied must never be reset from a materialized default. See
|
|
266
|
+
# _conflict_update_fields.
|
|
267
|
+
query["update_fields"] = update_fields or _conflict_update_fields(
|
|
268
|
+
model_cls, supplied_keys, conflict_fields
|
|
269
|
+
)
|
|
219
270
|
|
|
220
271
|
executor = QueryExecutor(query)
|
|
221
272
|
created_instances = await executor.bulk_upsert(query)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "matrx-orm"
|
|
3
|
-
version = "3.1.
|
|
3
|
+
version = "3.1.34"
|
|
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" }
|
|
@@ -90,15 +90,25 @@ class ProjectModel(_FakeBase):
|
|
|
90
90
|
_table_name = "ctx_projects"
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
class VocabModel(_FakeBase):
|
|
94
|
+
"""System-shared dictionary data (e.g. seo.keyword / seo.topic) — the
|
|
95
|
+
generator stamps `_rls_variant = "system"` on such models."""
|
|
96
|
+
|
|
97
|
+
_table_name = "keywords"
|
|
98
|
+
_rls_variant = "system"
|
|
99
|
+
|
|
100
|
+
|
|
93
101
|
@pytest.fixture(autouse=True)
|
|
94
102
|
def _reset_rows():
|
|
95
|
-
for cls in (EntityTypeRow, AssociationRow, NoteModel, ProjectModel):
|
|
103
|
+
for cls in (EntityTypeRow, AssociationRow, NoteModel, ProjectModel, VocabModel):
|
|
96
104
|
cls._rows = {}
|
|
97
105
|
yield
|
|
98
106
|
|
|
99
107
|
|
|
100
108
|
def _token_registry():
|
|
101
|
-
return EntityRegistry(
|
|
109
|
+
return EntityRegistry(
|
|
110
|
+
token_map={"note": NoteModel, "project": ProjectModel, "keyword": VocabModel}
|
|
111
|
+
)
|
|
102
112
|
|
|
103
113
|
|
|
104
114
|
def _assoc():
|
|
@@ -223,6 +233,35 @@ async def test_associate_verify_rejects_absent_endpoint():
|
|
|
223
233
|
await a.associate(EntityRef("note", "n1"), EntityRef("project", "p1"), org_id="org1")
|
|
224
234
|
|
|
225
235
|
|
|
236
|
+
@pytest.mark.asyncio
|
|
237
|
+
async def test_associate_allows_system_vocabulary_target_cross_org():
|
|
238
|
+
# seo.keyword / seo.topic pattern: the target lives in the SYSTEM org by
|
|
239
|
+
# design (_rls_variant="system"); the org-equality gate must not refuse it.
|
|
240
|
+
a = _assoc()
|
|
241
|
+
NoteModel.seed(id="n1", org_id="org1")
|
|
242
|
+
VocabModel.seed(id="k1", org_id="system-org")
|
|
243
|
+
edge = await a.associate(EntityRef("note", "n1"), EntityRef("keyword", "k1"), org_id="org1")
|
|
244
|
+
assert edge.org_id == "org1" # the edge carries the CALLER's org
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@pytest.mark.asyncio
|
|
248
|
+
async def test_associate_system_vocabulary_still_requires_existence():
|
|
249
|
+
a = _assoc()
|
|
250
|
+
NoteModel.seed(id="n1", org_id="org1")
|
|
251
|
+
with pytest.raises(CrossOrgAssociationError):
|
|
252
|
+
await a.associate(EntityRef("note", "n1"), EntityRef("keyword", "ghost"), org_id="org1")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@pytest.mark.asyncio
|
|
256
|
+
async def test_load_gated_returns_system_vocabulary_cross_org():
|
|
257
|
+
a = _assoc()
|
|
258
|
+
NoteModel.seed(id="n1", org_id="org1")
|
|
259
|
+
VocabModel.seed(id="k1", org_id="system-org")
|
|
260
|
+
await a.associate(EntityRef("note", "n1"), EntityRef("keyword", "k1"), org_id="org1")
|
|
261
|
+
targets = await a.targets_from(EntityRef("note", "n1"), "keyword", org_id="org1")
|
|
262
|
+
assert [t.id for t in targets] == ["k1"]
|
|
263
|
+
|
|
264
|
+
|
|
226
265
|
@pytest.mark.asyncio
|
|
227
266
|
async def test_associate_verify_false_skips_endpoint_check():
|
|
228
267
|
a = _assoc() # no endpoints seeded at all
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Level 1: an upsert's ``DO UPDATE SET`` must touch ONLY caller-supplied columns.
|
|
2
|
+
|
|
3
|
+
REGRESSION — 2026-07-25 silent platform-wide data corruption.
|
|
4
|
+
|
|
5
|
+
``upsert`` / ``bulk_upsert`` / ``upsert_with_conflict`` materialize every model
|
|
6
|
+
field's Python-level default so the INSERT half of the statement is complete.
|
|
7
|
+
Those defaults were then also landing in ``ON CONFLICT DO UPDATE SET``, so every
|
|
8
|
+
re-upsert of an EXISTING row silently reset each defaulted column back to its
|
|
9
|
+
default — no error, no log.
|
|
10
|
+
|
|
11
|
+
Real damage: ``research/search.py`` upserts each search hit while deliberately
|
|
12
|
+
omitting ``scrape_status`` / ``server_attempts`` / ``is_included``, expecting
|
|
13
|
+
DO UPDATE to leave them alone. Instead, every re-discovery of an already-known
|
|
14
|
+
URL reset a successfully scraped AND analyzed source back to
|
|
15
|
+
``scrape_status='pending', server_attempts=0`` — after which a stuck-row
|
|
16
|
+
watchdog finalized it as ``'failed'``. A user's manual "exclude this source"
|
|
17
|
+
was silently undone by the same mechanism.
|
|
18
|
+
|
|
19
|
+
``save()`` already had this discipline (it writes only loaded/changed columns);
|
|
20
|
+
the upsert paths never did.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import pytest
|
|
29
|
+
from matrx_orm import ConflictTarget
|
|
30
|
+
from matrx_orm.core.fields import BooleanField, IntegerField, TextField
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Meta:
|
|
34
|
+
def __init__(self, qualified: str) -> None:
|
|
35
|
+
self.qualified_table_name = qualified
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _source_model() -> type:
|
|
39
|
+
"""Mirrors the shape of ``research.rs_source`` that actually got corrupted:
|
|
40
|
+
caller-supplied identity columns plus defaulted state columns."""
|
|
41
|
+
fields: dict[str, Any] = {
|
|
42
|
+
"topic_id": TextField(),
|
|
43
|
+
"url": TextField(),
|
|
44
|
+
"title": TextField(),
|
|
45
|
+
"description": TextField(),
|
|
46
|
+
# The state columns. Every one carries a Python-level default, which is
|
|
47
|
+
# exactly what leaked into DO UPDATE SET.
|
|
48
|
+
"scrape_status": TextField(default="pending"),
|
|
49
|
+
"server_attempts": IntegerField(default=0),
|
|
50
|
+
"server_gave_up": BooleanField(default=False),
|
|
51
|
+
"is_included": BooleanField(default=True),
|
|
52
|
+
"is_stale": BooleanField(default=False),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class _RsSource:
|
|
56
|
+
_meta = _Meta("research.rs_source")
|
|
57
|
+
_fields = fields
|
|
58
|
+
_read_only = False
|
|
59
|
+
_database = "test_db" # QueryBuilder.__init__ resolves this before _build_query
|
|
60
|
+
|
|
61
|
+
def __init__(self, **kw: Any) -> None:
|
|
62
|
+
for name in fields:
|
|
63
|
+
setattr(self, name, None)
|
|
64
|
+
for k, v in kw.items():
|
|
65
|
+
setattr(self, k, v)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def get_database_name(cls) -> str:
|
|
69
|
+
return "test_db"
|
|
70
|
+
|
|
71
|
+
return _RsSource
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _set_clause(sql: str) -> str:
|
|
75
|
+
match = re.search(r"DO UPDATE SET (.*?) RETURNING", sql, re.DOTALL)
|
|
76
|
+
assert match, f"no DO UPDATE SET found in: {sql}"
|
|
77
|
+
return match.group(1)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _set_columns(sql: str) -> set[str]:
|
|
81
|
+
"""Assignment targets in the SET clause. Splits on TOP-LEVEL commas only —
|
|
82
|
+
bulk_upsert renders `col = COALESCE(EXCLUDED.col, table.col)` for
|
|
83
|
+
partially-populated columns, whose inner comma must not split an entry."""
|
|
84
|
+
clause = _set_clause(sql)
|
|
85
|
+
parts: list[str] = []
|
|
86
|
+
depth = 0
|
|
87
|
+
current = ""
|
|
88
|
+
for char in clause:
|
|
89
|
+
if char == "(":
|
|
90
|
+
depth += 1
|
|
91
|
+
elif char == ")":
|
|
92
|
+
depth -= 1
|
|
93
|
+
if char == "," and depth == 0:
|
|
94
|
+
parts.append(current)
|
|
95
|
+
current = ""
|
|
96
|
+
continue
|
|
97
|
+
current += char
|
|
98
|
+
parts.append(current)
|
|
99
|
+
return {part.split("=")[0].strip() for part in parts if part.strip()}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# The columns whose silent reset caused the incident.
|
|
103
|
+
STATE_COLUMNS = {
|
|
104
|
+
"scrape_status",
|
|
105
|
+
"server_attempts",
|
|
106
|
+
"server_gave_up",
|
|
107
|
+
"is_included",
|
|
108
|
+
"is_stale",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.fixture
|
|
113
|
+
def captured_sql(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, tuple[Any, ...]]]:
|
|
114
|
+
"""Intercept at the executor boundary so create.py's real logic runs."""
|
|
115
|
+
calls: list[tuple[str, tuple[Any, ...]]] = []
|
|
116
|
+
|
|
117
|
+
from matrx_orm.query import executor as executor_mod
|
|
118
|
+
|
|
119
|
+
class _DBStub:
|
|
120
|
+
async def execute_write(self, sql: str, *params: Any) -> list[dict[str, Any]]:
|
|
121
|
+
calls.append((sql, params))
|
|
122
|
+
return [{"id": "row-1"}]
|
|
123
|
+
|
|
124
|
+
def _fake_init(self: Any, query: dict[str, Any]) -> None:
|
|
125
|
+
self.query = query
|
|
126
|
+
self.model = query.get("model")
|
|
127
|
+
self.db = _DBStub()
|
|
128
|
+
|
|
129
|
+
async def _no_dict(self: Any, _query: dict[str, Any]) -> Any:
|
|
130
|
+
return NotImplemented
|
|
131
|
+
|
|
132
|
+
def _guard(self: Any, _op: str) -> None:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
def _make(self: Any, **row: Any) -> Any:
|
|
136
|
+
return row
|
|
137
|
+
|
|
138
|
+
monkeypatch.setattr(executor_mod.QueryExecutor, "__init__", _fake_init)
|
|
139
|
+
monkeypatch.setattr(executor_mod.QueryExecutor, "_try_write_dict", _no_dict)
|
|
140
|
+
monkeypatch.setattr(executor_mod.QueryExecutor, "_guard_writable", _guard)
|
|
141
|
+
monkeypatch.setattr(executor_mod.QueryExecutor, "_make", _make)
|
|
142
|
+
|
|
143
|
+
from matrx_orm.query import builder as builder_mod
|
|
144
|
+
|
|
145
|
+
def _fake_build(self: Any) -> dict[str, Any]:
|
|
146
|
+
return {"table": self.model._meta.qualified_table_name, "model": self.model}
|
|
147
|
+
|
|
148
|
+
monkeypatch.setattr(builder_mod.QueryBuilder, "_build_query", _fake_build)
|
|
149
|
+
|
|
150
|
+
from matrx_orm import state as state_mod
|
|
151
|
+
|
|
152
|
+
async def _no_cache(*_a: Any, **_kw: Any) -> None:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
monkeypatch.setattr(state_mod.StateManager, "cache", _no_cache)
|
|
156
|
+
|
|
157
|
+
return calls
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@pytest.mark.asyncio
|
|
161
|
+
async def test_upsert_does_not_reset_defaulted_columns_caller_omitted(
|
|
162
|
+
captured_sql: list[tuple[str, tuple[Any, ...]]],
|
|
163
|
+
) -> None:
|
|
164
|
+
"""THE regression. A search re-discovering a known URL supplies only
|
|
165
|
+
identity + descriptive columns; the row's scrape state must survive."""
|
|
166
|
+
from matrx_orm.operations.create import upsert
|
|
167
|
+
|
|
168
|
+
await upsert(
|
|
169
|
+
_source_model(),
|
|
170
|
+
{
|
|
171
|
+
"topic_id": "t1",
|
|
172
|
+
"url": "https://example.com/a",
|
|
173
|
+
"title": "A",
|
|
174
|
+
"description": "desc",
|
|
175
|
+
},
|
|
176
|
+
["topic_id", "url"],
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
sql, _ = captured_sql[0]
|
|
180
|
+
set_cols = _set_columns(sql)
|
|
181
|
+
|
|
182
|
+
leaked = set_cols & STATE_COLUMNS
|
|
183
|
+
assert not leaked, f"defaulted state columns leaked into DO UPDATE SET: {sorted(leaked)}"
|
|
184
|
+
assert set_cols == {"title", "description"}
|
|
185
|
+
|
|
186
|
+
# The INSERT half MUST still carry the defaults — a brand-new row needs them.
|
|
187
|
+
insert_cols = sql.split("(", 1)[1].split(")", 1)[0]
|
|
188
|
+
for column in STATE_COLUMNS:
|
|
189
|
+
assert column in insert_cols, f"{column} missing from the INSERT column list"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@pytest.mark.asyncio
|
|
193
|
+
async def test_upsert_still_updates_columns_the_caller_did_supply(
|
|
194
|
+
captured_sql: list[tuple[str, tuple[Any, ...]]],
|
|
195
|
+
) -> None:
|
|
196
|
+
"""The fix must not over-correct: an explicitly supplied state column is a
|
|
197
|
+
deliberate write and must still land."""
|
|
198
|
+
from matrx_orm.operations.create import upsert
|
|
199
|
+
|
|
200
|
+
await upsert(
|
|
201
|
+
_source_model(),
|
|
202
|
+
{
|
|
203
|
+
"topic_id": "t1",
|
|
204
|
+
"url": "https://example.com/a",
|
|
205
|
+
"scrape_status": "success",
|
|
206
|
+
"server_attempts": 3,
|
|
207
|
+
},
|
|
208
|
+
["topic_id", "url"],
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
set_cols = _set_columns(captured_sql[0][0])
|
|
212
|
+
assert set_cols == {"scrape_status", "server_attempts"}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@pytest.mark.asyncio
|
|
216
|
+
async def test_explicit_update_fields_still_wins(
|
|
217
|
+
captured_sql: list[tuple[str, tuple[Any, ...]]],
|
|
218
|
+
) -> None:
|
|
219
|
+
from matrx_orm.operations.create import upsert
|
|
220
|
+
|
|
221
|
+
await upsert(
|
|
222
|
+
_source_model(),
|
|
223
|
+
{"topic_id": "t1", "url": "https://example.com/a", "title": "A"},
|
|
224
|
+
["topic_id", "url"],
|
|
225
|
+
update_fields=["title", "is_stale"],
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
assert _set_columns(captured_sql[0][0]) == {"title", "is_stale"}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@pytest.mark.asyncio
|
|
232
|
+
async def test_upsert_of_only_conflict_keys_reconciles_to_noop(
|
|
233
|
+
captured_sql: list[tuple[str, tuple[Any, ...]]],
|
|
234
|
+
) -> None:
|
|
235
|
+
""""Ensure this row exists" has exactly one meaning, so it reconciles to a
|
|
236
|
+
self-assignment rather than raising — and must NOT reset state columns.
|
|
237
|
+
A bare DO NOTHING would break the RETURNING * contract."""
|
|
238
|
+
from matrx_orm.operations.create import upsert
|
|
239
|
+
|
|
240
|
+
await upsert(
|
|
241
|
+
_source_model(),
|
|
242
|
+
{"topic_id": "t1", "url": "https://example.com/a"},
|
|
243
|
+
["topic_id", "url"],
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
sql = captured_sql[0][0]
|
|
247
|
+
assert not _set_columns(sql) & STATE_COLUMNS
|
|
248
|
+
assert "RETURNING" in sql
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@pytest.mark.asyncio
|
|
252
|
+
async def test_caller_supplied_none_does_not_write_the_column_default(
|
|
253
|
+
captured_sql: list[tuple[str, tuple[Any, ...]]],
|
|
254
|
+
) -> None:
|
|
255
|
+
"""A None is dropped from the INSERT, so ``EXCLUDED.<col>`` would resolve to
|
|
256
|
+
the column DEFAULT — reintroducing the bug through the back door."""
|
|
257
|
+
from matrx_orm.operations.create import upsert
|
|
258
|
+
|
|
259
|
+
await upsert(
|
|
260
|
+
_source_model(),
|
|
261
|
+
{
|
|
262
|
+
"topic_id": "t1",
|
|
263
|
+
"url": "https://example.com/a",
|
|
264
|
+
"title": "A",
|
|
265
|
+
"description": None,
|
|
266
|
+
},
|
|
267
|
+
["topic_id", "url"],
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
assert "description" not in _set_columns(captured_sql[0][0])
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@pytest.mark.asyncio
|
|
274
|
+
async def test_bulk_upsert_uses_union_of_caller_supplied_keys(
|
|
275
|
+
captured_sql: list[tuple[str, tuple[Any, ...]]],
|
|
276
|
+
) -> None:
|
|
277
|
+
from matrx_orm.operations.create import bulk_upsert
|
|
278
|
+
|
|
279
|
+
await bulk_upsert(
|
|
280
|
+
_source_model(),
|
|
281
|
+
[
|
|
282
|
+
{"topic_id": "t1", "url": "https://example.com/a", "title": "A"},
|
|
283
|
+
{"topic_id": "t1", "url": "https://example.com/b", "description": "D"},
|
|
284
|
+
],
|
|
285
|
+
["topic_id", "url"],
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
set_cols = _set_columns(captured_sql[0][0])
|
|
289
|
+
assert not set_cols & STATE_COLUMNS
|
|
290
|
+
assert set_cols == {"title", "description"}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@pytest.mark.asyncio
|
|
294
|
+
async def test_expression_conflict_target_also_excludes_defaults(
|
|
295
|
+
captured_sql: list[tuple[str, tuple[Any, ...]]],
|
|
296
|
+
) -> None:
|
|
297
|
+
"""The ConflictTarget branch defaults to `update_fields or columns` — i.e.
|
|
298
|
+
ALL columns — so it leaked the same way."""
|
|
299
|
+
from matrx_orm.operations.create import upsert
|
|
300
|
+
|
|
301
|
+
await upsert(
|
|
302
|
+
_source_model(),
|
|
303
|
+
{"topic_id": "t1", "url": "https://example.com/a", "title": "A"},
|
|
304
|
+
ConflictTarget.partial(["topic_id", "url"], md5=("url",)),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
assert not _set_columns(captured_sql[0][0]) & STATE_COLUMNS
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|