plain.postgres 0.103.5__tar.gz → 0.104.0__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.
- plain_postgres-0.103.5/plain/postgres/README.md → plain_postgres-0.104.0/PKG-INFO +30 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/CHANGELOG.md +20 -0
- plain_postgres-0.103.5/PKG-INFO → plain_postgres-0.104.0/plain/postgres/README.md +17 -13
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +1 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/base.py +47 -56
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/constraints.py +1 -1
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/base.py +35 -29
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/related.py +38 -58
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/related_descriptors.py +101 -79
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/related_lookups.py +1 -1
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/related_managers.py +22 -32
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/temporal.py +3 -2
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/meta.py +4 -26
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/query.py +16 -21
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/sql/compiler.py +3 -3
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/sql/query.py +5 -9
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/pyproject.toml +1 -1
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/models/delete.py +0 -5
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/models/relationships.py +0 -2
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/models/trees.py +0 -1
- plain_postgres-0.104.0/tests/internal/test_fk_characterization.py +156 -0
- plain_postgres-0.104.0/tests/public/test_deferred_loading.py +91 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/public/test_delete_behaviors.py +7 -9
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/public/test_modelform_roundtrip.py +1 -1
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/public/test_related.py +197 -2
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/.gitignore +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/CLAUDE.md +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/LICENSE +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/README.md +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/__init__.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/adapters.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/cli/decorators.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/cli/sync.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/connection.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/convergence/analysis.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/convergence/fixes.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/convergence/planning.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/expressions.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/binary.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/fields/uuid.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/introspection/health/__init__.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/introspection/health/context.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/introspection/health/helpers.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/introspection/health/ownership.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/introspection/health/runner.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/introspection/health/types.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/introspection/schema.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/middleware.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/preflight.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/sources.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/test/database.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/types.pyi +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/models/__init__.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/models/constraints.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/models/defaults.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/models/encrypted.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/models/forms.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/models/indexes.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/models/iteration.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/models/mixins.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/models/nullability.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/models/querysets.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/models/storage_parameters.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/conftest.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_autodetector_not_null_errors.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_connection_isolation.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_connection_pool.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_constraint_violation_error.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_convergence.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_convergence_constraints.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_convergence_defaults.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_convergence_fk.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_convergence_indexes.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_convergence_nullability.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_convergence_timeouts.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_db_expression_defaults.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_diagnose.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_executor_connection_hook.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_health.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_introspection.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_literal_default_persistence.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_management_connection.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_migration_executor.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_otel_metrics.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_schema_normalize_type.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/internal/test_schema_timeouts.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/public/test_database_url.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/public/test_encrypted_fields.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/public/test_exceptions.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/public/test_field_defaults.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/public/test_functions_uuid.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/public/test_iterator.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/public/test_m2m.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/public/test_manager_assignment.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/public/test_mixins.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/public/test_queryset_repr.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/public/test_random_string_field.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/public/test_raw_query.py +0 -0
- {plain_postgres-0.103.5 → plain_postgres-0.104.0}/tests/public/test_read_only_transactions.py +0 -0
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: plain.postgres
|
|
3
|
+
Version: 0.104.0
|
|
4
|
+
Summary: Model your data and store it in a database.
|
|
5
|
+
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Requires-Dist: plain<1.0.0,>=0.134.0
|
|
10
|
+
Requires-Dist: psycopg-pool>=3.2
|
|
11
|
+
Requires-Dist: psycopg>=3.2
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
1
14
|
# plain.postgres
|
|
2
15
|
|
|
3
16
|
**Model your data and store it in a database.**
|
|
@@ -410,6 +423,8 @@ posts = Post.query.all()
|
|
|
410
423
|
posts = Post.query.defer("body").all()
|
|
411
424
|
```
|
|
412
425
|
|
|
426
|
+
Reading any deferred field on an instance loads every still-missing column in a single query, not just the field you asked for. Defer columns you don't intend to access on that instance, and prefer `.values()` / `.values_list()` when you want one column without ever materializing the rest. The foreign key column is the exception: accessing a deferred foreign key loads only the foreign key column, so the partial-related-instance shortcut stays cheap.
|
|
427
|
+
|
|
413
428
|
#### Use `.iterator()` for large result sets
|
|
414
429
|
|
|
415
430
|
Process rows in chunks instead of loading everything into memory.
|
|
@@ -929,6 +944,21 @@ class Book(postgres.Model):
|
|
|
929
944
|
tags = types.ManyToManyField("Tag")
|
|
930
945
|
```
|
|
931
946
|
|
|
947
|
+
### Foreign key access
|
|
948
|
+
|
|
949
|
+
Accessing a foreign key gives you the related object without a query — only its primary key is loaded up front:
|
|
950
|
+
|
|
951
|
+
```python
|
|
952
|
+
book = Book.query.get(id=1)
|
|
953
|
+
book.author # no query — a partial Author instance
|
|
954
|
+
book.author.id # no query — the foreign key value
|
|
955
|
+
book.author.name # one query — loads the rest of the row
|
|
956
|
+
```
|
|
957
|
+
|
|
958
|
+
The first access to any non-key field loads the whole row in a single query. There is no separate `author_id` attribute — `book.author.id` is the foreign key value, and it is type-checked because `book.author` is an `Author`. In loops, use `select_related()` to load related rows up front and avoid a query per row.
|
|
959
|
+
|
|
960
|
+
The partial-instance shortcut relies on the database guaranteeing the row exists. A foreign key declared with `db_constraint=False` has no such guarantee, so it is queried on access instead — a stale key raises `DoesNotExist` right away rather than yielding a placeholder.
|
|
961
|
+
|
|
932
962
|
### Reverse relationships
|
|
933
963
|
|
|
934
964
|
When you define a `ForeignKey` or `ManyToManyField`, Plain automatically creates a reverse accessor on the related model (like `author.book_set`). You can explicitly declare these reverse relationships using [`ReverseForeignKey`](./fields/reverse_descriptors.py#ReverseForeignKey) and [`ReverseManyToMany`](./fields/reverse_descriptors.py#ReverseManyToMany):
|
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
+
## [0.104.0](https://github.com/dropseed/plain/releases/plain-postgres@0.104.0) (2026-05-22)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- **Foreign keys now return a partial related instance instead of an `_id` attribute.** Reading `book.author` is query-free — it returns an `Author` instance with only its primary key loaded. `book.author.id` gives the foreign key value, and accessing any other field (`book.author.name`) triggers a single deferred-load query for the whole row. The descriptor caches the resolved object so subsequent accesses do not re-query. `select_related()` continues to populate the cache up front for loops. ([b3be028b7b](https://github.com/dropseed/plain/commit/b3be028b7b))
|
|
8
|
+
- **The `<fk>_id` shadow attribute is gone.** There is no `book.author_id` anymore — the raw foreign key column lives in `instance.__dict__` under the field name (`book.__dict__["author"]`) and is reached through the descriptor. `book.author.id` is the replacement read path and is type-checked because `book.author` is an `Author`. Writes that previously assigned `book.author_id = 5` now need to assign the key (or a related instance) through `book.author = 5` — the descriptor accepts a bare primary key value, a model instance, or `None`. ([3baa1c0404](https://github.com/dropseed/plain/commit/3baa1c0404), [b3be028b7b](https://github.com/dropseed/plain/commit/b3be028b7b))
|
|
9
|
+
- **`Field.attname` and `Field.get_attname()` are removed.** Every internal use was the same as `Field.name`; the dual-keying only existed to track the `_id` suffix on foreign keys, which now lives on `Field.column` instead. Use `field.name` for the attribute (and instance-dict key) and `field.column` for the database column. ([6a462a874c](https://github.com/dropseed/plain/commit/6a462a874c), [e556bb7d63](https://github.com/dropseed/plain/commit/e556bb7d63))
|
|
10
|
+
- **Foreign keys with `db_constraint=False` are queried on access** rather than returning a partial instance. Without a database FK constraint a stale key cannot be assumed valid, so the descriptor issues a `SELECT` and raises `RelatedObjectDoesNotExist` immediately if the row is missing — the same exception an empty foreign key raises. Constrained foreign keys (the default) keep the no-query partial-instance shortcut. ([d341d902b4](https://github.com/dropseed/plain/commit/d341d902b4))
|
|
11
|
+
- **Reading a deferred field now hydrates every still-missing concrete column in one query**, not just the field you accessed. This is what makes the partial foreign-key instance cheap to use beyond its primary key — first non-key access loads the whole row. The foreign key column itself is the exception and is loaded on its own when deferred via `.only()` / `.defer()`, so the partial-instance shortcut stays cheap. ([2440e415cb](https://github.com/dropseed/plain/commit/2440e415cb))
|
|
12
|
+
- The descriptor's deferred foreign-key load now goes through `_get_raw_value()`, so a `.only()` queryset that excludes the FK column still resolves the related object on access without mistaking the deferred column for `NULL`. ([d9765979ba](https://github.com/dropseed/plain/commit/d9765979ba))
|
|
13
|
+
- `Field.__delete__` reordered so a missing entry raises `AttributeError` without first wiping the related-object cache — repeated `del`s no longer silently evict cached descriptors. ([b8949f00d0](https://github.com/dropseed/plain/commit/b8949f00d0))
|
|
14
|
+
- Internal cleanup: removed the unused `ForeignKeyField.reverse_related_fields` property, the dead composite-key branches in foreign-key resolution, and the unreachable code left over from the `attname` collapse. ([98b27c5f8a](https://github.com/dropseed/plain/commit/98b27c5f8a), [92e905f25e](https://github.com/dropseed/plain/commit/92e905f25e), [bfcac9a435](https://github.com/dropseed/plain/commit/bfcac9a435))
|
|
15
|
+
|
|
16
|
+
### Upgrade instructions
|
|
17
|
+
|
|
18
|
+
- **Audit every `<fk>_id` access** — these attributes no longer exist. Reads (`obj.author_id`) and especially writes (`obj.author_id = 5`) need to move to the field name: `obj.author.id` for reads, `obj.author = 5` for writes. **Pay particular attention to assignments under `# type: ignore` or other suppression** — a stray `obj.author_id = ...` write now creates a plain Python attribute on the instance and silently does nothing on save, because the ORM only persists the descriptor-backed value at `obj.author`. Grep for `_id =`, `_id=` (in `.create()` / `.update()` kwargs), and `_id` template/queryset references on your models. Querysets and lookups (`Author.query.filter(book__author_id=5)`) are unaffected — only attribute access on instances changed.
|
|
19
|
+
- **Stop reading `Field.attname` / calling `Field.get_attname()`.** Any custom field or introspection code that referenced these attributes must switch to `field.name` (the Python attribute) or `field.column` (the database column).
|
|
20
|
+
- **Foreign keys declared with `db_constraint=False` now issue a query on first access** (previously: no query, partial instance). If you relied on accessing a constraint-less FK without a query, the behavior is intentionally stricter — a stale key now fails fast with `RelatedObjectDoesNotExist` instead of yielding a phantom instance that broke only on field access. Use `select_related()` to load these up front in loops.
|
|
21
|
+
- **`.only()` / `.defer()` field lists**: deferred non-FK columns no longer load one-at-a-time. If you `.defer("body")` and later read `instance.body`, the resulting query now refreshes every still-deferred column in the same instance — defer only the columns you do not intend to read.
|
|
22
|
+
|
|
3
23
|
## [0.103.5](https://github.com/dropseed/plain/releases/plain-postgres@0.103.5) (2026-05-19)
|
|
4
24
|
|
|
5
25
|
### What's changed
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: plain.postgres
|
|
3
|
-
Version: 0.103.5
|
|
4
|
-
Summary: Model your data and store it in a database.
|
|
5
|
-
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
|
-
License-Expression: BSD-3-Clause
|
|
7
|
-
License-File: LICENSE
|
|
8
|
-
Requires-Python: >=3.13
|
|
9
|
-
Requires-Dist: plain<1.0.0,>=0.134.0
|
|
10
|
-
Requires-Dist: psycopg-pool>=3.2
|
|
11
|
-
Requires-Dist: psycopg>=3.2
|
|
12
|
-
Description-Content-Type: text/markdown
|
|
13
|
-
|
|
14
1
|
# plain.postgres
|
|
15
2
|
|
|
16
3
|
**Model your data and store it in a database.**
|
|
@@ -423,6 +410,8 @@ posts = Post.query.all()
|
|
|
423
410
|
posts = Post.query.defer("body").all()
|
|
424
411
|
```
|
|
425
412
|
|
|
413
|
+
Reading any deferred field on an instance loads every still-missing column in a single query, not just the field you asked for. Defer columns you don't intend to access on that instance, and prefer `.values()` / `.values_list()` when you want one column without ever materializing the rest. The foreign key column is the exception: accessing a deferred foreign key loads only the foreign key column, so the partial-related-instance shortcut stays cheap.
|
|
414
|
+
|
|
426
415
|
#### Use `.iterator()` for large result sets
|
|
427
416
|
|
|
428
417
|
Process rows in chunks instead of loading everything into memory.
|
|
@@ -942,6 +931,21 @@ class Book(postgres.Model):
|
|
|
942
931
|
tags = types.ManyToManyField("Tag")
|
|
943
932
|
```
|
|
944
933
|
|
|
934
|
+
### Foreign key access
|
|
935
|
+
|
|
936
|
+
Accessing a foreign key gives you the related object without a query — only its primary key is loaded up front:
|
|
937
|
+
|
|
938
|
+
```python
|
|
939
|
+
book = Book.query.get(id=1)
|
|
940
|
+
book.author # no query — a partial Author instance
|
|
941
|
+
book.author.id # no query — the foreign key value
|
|
942
|
+
book.author.name # one query — loads the rest of the row
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
The first access to any non-key field loads the whole row in a single query. There is no separate `author_id` attribute — `book.author.id` is the foreign key value, and it is type-checked because `book.author` is an `Author`. In loops, use `select_related()` to load related rows up front and avoid a query per row.
|
|
946
|
+
|
|
947
|
+
The partial-instance shortcut relies on the database guaranteeing the row exists. A foreign key declared with `db_constraint=False` has no such guarantee, so it is queried on access instead — a stale key raises `DoesNotExist` right away rather than yielding a placeholder.
|
|
948
|
+
|
|
945
949
|
### Reverse relationships
|
|
946
950
|
|
|
947
951
|
When you define a `ForeignKey` or `ManyToManyField`, Plain automatically creates a reverse accessor on the related model (like `author.book_set`). You can explicitly declare these reverse relationships using [`ReverseForeignKey`](./fields/reverse_descriptors.py#ReverseForeignKey) and [`ReverseManyToMany`](./fields/reverse_descriptors.py#ReverseManyToMany):
|
|
@@ -49,6 +49,7 @@ Run `uv run plain docs postgres` for full workflow details.
|
|
|
49
49
|
Use `Model.query` to build querysets (e.g., `User.query.filter(is_active=True)`).
|
|
50
50
|
|
|
51
51
|
- Use `select_related()` for FK access in loops, `prefetch_related()` for reverse/M2N
|
|
52
|
+
- A foreign key returns a partial related object: `obj.author` and `obj.author.id` are query-free; other fields load on first access. There is no `obj.author_id` — use `obj.author.id`
|
|
52
53
|
- Use `.annotate(Count(...))` instead of calling `.count()` per row
|
|
53
54
|
- Fetch all data in the view — templates should never trigger queries
|
|
54
55
|
- Use `.exists()` not `.count() > 0`, `.count()` not `len(qs)`
|
|
@@ -113,24 +113,21 @@ class Model(metaclass=ModelBase):
|
|
|
113
113
|
|
|
114
114
|
is_related_object = False
|
|
115
115
|
# Virtual field
|
|
116
|
-
if field.
|
|
116
|
+
if field.name not in kwargs and field.column is None:
|
|
117
117
|
continue
|
|
118
118
|
if isinstance(field, RelatedField) and isinstance(
|
|
119
119
|
field.remote_field, ForeignObjectRel
|
|
120
120
|
):
|
|
121
121
|
try:
|
|
122
|
-
#
|
|
122
|
+
# A foreign key is given by name -- either a related
|
|
123
|
+
# instance or a bare primary key value.
|
|
123
124
|
rel_obj = kwargs.pop(field.name)
|
|
124
125
|
is_related_object = True
|
|
125
126
|
except KeyError:
|
|
126
|
-
|
|
127
|
-
# Object instance wasn't passed in -- must be an ID.
|
|
128
|
-
val = kwargs.pop(field.attname)
|
|
129
|
-
except KeyError:
|
|
130
|
-
val = field.get_default()
|
|
127
|
+
val = field.get_default()
|
|
131
128
|
else:
|
|
132
129
|
try:
|
|
133
|
-
val = kwargs.pop(field.
|
|
130
|
+
val = kwargs.pop(field.name)
|
|
134
131
|
except KeyError:
|
|
135
132
|
# This is done with an exception rather than the
|
|
136
133
|
# default argument on pop because we don't want
|
|
@@ -145,15 +142,13 @@ class Model(metaclass=ModelBase):
|
|
|
145
142
|
val = field.get_default()
|
|
146
143
|
|
|
147
144
|
if is_related_object:
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
# "user_id") so that the object gets properly cached (and type
|
|
151
|
-
# checked) by the RelatedObjectDescriptor.
|
|
145
|
+
# Assign through the descriptor so a related instance is
|
|
146
|
+
# cached and a bare key value is stored correctly.
|
|
152
147
|
if rel_obj is not _DEFERRED:
|
|
153
148
|
_setattr(self, field.name, rel_obj)
|
|
154
149
|
else:
|
|
155
150
|
if val is not _DEFERRED:
|
|
156
|
-
_setattr(self, field.
|
|
151
|
+
_setattr(self, field.name, val)
|
|
157
152
|
|
|
158
153
|
# Handle any remaining kwargs (properties or virtual fields)
|
|
159
154
|
property_names = meta._property_names
|
|
@@ -185,12 +180,12 @@ class Model(metaclass=ModelBase):
|
|
|
185
180
|
if len(values) != len(cls._model_meta.concrete_fields):
|
|
186
181
|
values_iter = iter(values)
|
|
187
182
|
values = [
|
|
188
|
-
next(values_iter) if f.
|
|
183
|
+
next(values_iter) if f.name in field_names else DEFERRED
|
|
189
184
|
for f in cls._model_meta.concrete_fields
|
|
190
185
|
]
|
|
191
186
|
# Build kwargs dict from field names and values
|
|
192
187
|
field_dict = dict(
|
|
193
|
-
zip((f.
|
|
188
|
+
zip((f.name for f in cls._model_meta.concrete_fields), values)
|
|
194
189
|
)
|
|
195
190
|
new = cls(**field_dict)
|
|
196
191
|
new._state.adding = False
|
|
@@ -269,9 +264,9 @@ class Model(metaclass=ModelBase):
|
|
|
269
264
|
Return a set containing names of deferred fields for this instance.
|
|
270
265
|
"""
|
|
271
266
|
return {
|
|
272
|
-
f.
|
|
267
|
+
f.name
|
|
273
268
|
for f in self._model_meta.concrete_fields
|
|
274
|
-
if f.
|
|
269
|
+
if f.name not in self.__dict__
|
|
275
270
|
}
|
|
276
271
|
|
|
277
272
|
def refresh_from_db(self, fields: list[str] | None = None) -> None:
|
|
@@ -309,19 +304,19 @@ class Model(metaclass=ModelBase):
|
|
|
309
304
|
db_instance_qs = db_instance_qs.only(*fields)
|
|
310
305
|
elif deferred_fields:
|
|
311
306
|
fields = [
|
|
312
|
-
f.
|
|
307
|
+
f.name
|
|
313
308
|
for f in self._model_meta.concrete_fields
|
|
314
|
-
if f.
|
|
309
|
+
if f.name not in deferred_fields
|
|
315
310
|
]
|
|
316
311
|
db_instance_qs = db_instance_qs.only(*fields)
|
|
317
312
|
|
|
318
313
|
db_instance = db_instance_qs.get()
|
|
319
314
|
non_loaded_fields = db_instance.get_deferred_fields()
|
|
320
315
|
for field in self._model_meta.concrete_fields:
|
|
321
|
-
if field.
|
|
316
|
+
if field.name in non_loaded_fields:
|
|
322
317
|
# This field wasn't refreshed - skip ahead.
|
|
323
318
|
continue
|
|
324
|
-
setattr(self, field.
|
|
319
|
+
setattr(self, field.name, field.value_from_object(db_instance))
|
|
325
320
|
# Clear cached foreign keys.
|
|
326
321
|
if isinstance(field, RelatedField) and field.is_cached(self):
|
|
327
322
|
field.delete_cached_value(self)
|
|
@@ -346,7 +341,7 @@ class Model(metaclass=ModelBase):
|
|
|
346
341
|
field = self._model_meta.get_forward_field(field_name)
|
|
347
342
|
except FieldDoesNotExist:
|
|
348
343
|
return getattr(self, field_name)
|
|
349
|
-
return
|
|
344
|
+
return field.value_from_object(self)
|
|
350
345
|
|
|
351
346
|
def save(
|
|
352
347
|
self,
|
|
@@ -393,7 +388,7 @@ class Model(metaclass=ModelBase):
|
|
|
393
388
|
field_names = set()
|
|
394
389
|
for field in self._model_meta.concrete_fields:
|
|
395
390
|
if not field.primary_key and not hasattr(field, "through"):
|
|
396
|
-
field_names.add(field.
|
|
391
|
+
field_names.add(field.name)
|
|
397
392
|
loaded_fields = field_names.difference(deferred_fields)
|
|
398
393
|
if loaded_fields:
|
|
399
394
|
update_fields = frozenset(loaded_fields)
|
|
@@ -454,11 +449,7 @@ class Model(metaclass=ModelBase):
|
|
|
454
449
|
non_pks = [f for f in meta.local_concrete_fields if not f.primary_key]
|
|
455
450
|
|
|
456
451
|
if update_fields:
|
|
457
|
-
non_pks = [
|
|
458
|
-
f
|
|
459
|
-
for f in non_pks
|
|
460
|
-
if f.name in update_fields or f.attname in update_fields
|
|
461
|
-
]
|
|
452
|
+
non_pks = [f for f in non_pks if f.name in update_fields]
|
|
462
453
|
|
|
463
454
|
id_field = meta.get_forward_field("id")
|
|
464
455
|
id_val = self.id
|
|
@@ -469,7 +460,7 @@ class Model(metaclass=ModelBase):
|
|
|
469
460
|
# stays None and the INSERT emits DEFAULT.
|
|
470
461
|
if isinstance(id_field, DefaultableField) and id_field.has_default():
|
|
471
462
|
id_val = id_field.get_default()
|
|
472
|
-
setattr(self, id_field.
|
|
463
|
+
setattr(self, id_field.name, id_val)
|
|
473
464
|
id_set = id_val is not None
|
|
474
465
|
if not id_set and (force_update or update_fields):
|
|
475
466
|
raise ValueError("Cannot force an update in save() with no primary key.")
|
|
@@ -488,7 +479,7 @@ class Model(metaclass=ModelBase):
|
|
|
488
479
|
if id_set and not force_insert:
|
|
489
480
|
base_qs = meta.base_queryset
|
|
490
481
|
values = [
|
|
491
|
-
(f, (
|
|
482
|
+
(f, (f.value_from_object(self) if raw else f.pre_save(self, False)))
|
|
492
483
|
for f in non_pks
|
|
493
484
|
]
|
|
494
485
|
# DATABASE_DEFAULT fields represent "let the DB produce this on
|
|
@@ -497,9 +488,7 @@ class Model(metaclass=ModelBase):
|
|
|
497
488
|
# below handles them correctly. If the UPDATE *does* succeed, we
|
|
498
489
|
# need to refresh those fields from the DB so the in-memory
|
|
499
490
|
# instance doesn't keep the sentinel.
|
|
500
|
-
|
|
501
|
-
v[0].attname for v in values if v[1] is DATABASE_DEFAULT
|
|
502
|
-
]
|
|
491
|
+
db_default_names = [v[0].name for v in values if v[1] is DATABASE_DEFAULT]
|
|
503
492
|
values = [v for v in values if v[1] is not DATABASE_DEFAULT]
|
|
504
493
|
forced_update = bool(update_fields or force_update)
|
|
505
494
|
updated = self._do_update(
|
|
@@ -511,8 +500,8 @@ class Model(metaclass=ModelBase):
|
|
|
511
500
|
raise psycopg.DatabaseError(
|
|
512
501
|
"Save with update_fields did not affect any rows."
|
|
513
502
|
)
|
|
514
|
-
if updated and
|
|
515
|
-
self.refresh_from_db(fields=
|
|
503
|
+
if updated and db_default_names:
|
|
504
|
+
self.refresh_from_db(fields=db_default_names)
|
|
516
505
|
if not updated:
|
|
517
506
|
fields = meta.local_concrete_fields
|
|
518
507
|
if not id_set:
|
|
@@ -523,7 +512,8 @@ class Model(metaclass=ModelBase):
|
|
|
523
512
|
results = self._do_insert(meta.base_queryset, fields, returning_fields, raw)
|
|
524
513
|
if results:
|
|
525
514
|
for value, field in zip(results[0], returning_fields):
|
|
526
|
-
|
|
515
|
+
assert field.name is not None
|
|
516
|
+
setattr(self, field.name, value)
|
|
527
517
|
return updated
|
|
528
518
|
|
|
529
519
|
def _do_update(
|
|
@@ -592,14 +582,16 @@ class Model(metaclass=ModelBase):
|
|
|
592
582
|
f"{operation_name}() prohibited to prevent data loss due to unsaved "
|
|
593
583
|
f"related object '{field.name}'."
|
|
594
584
|
)
|
|
595
|
-
elif
|
|
585
|
+
elif field.value_from_object(self) in field.empty_values:
|
|
596
586
|
# Set related object if it has been saved after an
|
|
597
587
|
# assignment.
|
|
598
588
|
setattr(self, field.name, obj)
|
|
599
589
|
# If the relationship's pk/to_field was changed, clear the
|
|
600
|
-
# cached relationship.
|
|
601
|
-
|
|
602
|
-
|
|
590
|
+
# cached relationship. Compare the cached object's key against
|
|
591
|
+
# the raw key value -- not getattr(self, field.name), which
|
|
592
|
+
# for a foreign key returns the related object, not the key.
|
|
593
|
+
if getattr(obj, field.target_field.name) != field.value_from_object(
|
|
594
|
+
self
|
|
603
595
|
):
|
|
604
596
|
field.delete_cached_value(self)
|
|
605
597
|
|
|
@@ -624,14 +616,16 @@ class Model(metaclass=ModelBase):
|
|
|
624
616
|
# blocks see the abort state even if the caller catches IntegrityError.
|
|
625
617
|
with transaction.mark_for_rollback_on_error():
|
|
626
618
|
count = self._model_meta.base_queryset.filter(id=self.id)._raw_delete()
|
|
627
|
-
|
|
619
|
+
id_field = self._model_meta.get_forward_field("id")
|
|
620
|
+
assert id_field.name is not None
|
|
621
|
+
setattr(self, id_field.name, None)
|
|
628
622
|
return count
|
|
629
623
|
|
|
630
624
|
def get_field_display(self, field_name: str) -> str:
|
|
631
625
|
"""Get the display value for a field, especially useful for fields with choices."""
|
|
632
626
|
# Get the field object from the field name
|
|
633
627
|
field = self._model_meta.get_forward_field(field_name)
|
|
634
|
-
value =
|
|
628
|
+
value = field.value_from_object(self)
|
|
635
629
|
|
|
636
630
|
# If field has no choices, just return the value as string
|
|
637
631
|
if not hasattr(field, "flatchoices") or not field.flatchoices:
|
|
@@ -650,7 +644,7 @@ class Model(metaclass=ModelBase):
|
|
|
650
644
|
exclude = set()
|
|
651
645
|
meta = meta or self._model_meta
|
|
652
646
|
return {
|
|
653
|
-
field.name: Value(
|
|
647
|
+
field.name: Value(field.value_from_object(self), field)
|
|
654
648
|
for field in meta.local_concrete_fields
|
|
655
649
|
if field.name not in exclude
|
|
656
650
|
}
|
|
@@ -660,7 +654,7 @@ class Model(metaclass=ModelBase):
|
|
|
660
654
|
raise ValueError(
|
|
661
655
|
f"Unsaved model instance {self!r} cannot be used in an ORM query."
|
|
662
656
|
)
|
|
663
|
-
return getattr(self, field.remote_field.get_related_field().
|
|
657
|
+
return getattr(self, field.remote_field.get_related_field().name)
|
|
664
658
|
|
|
665
659
|
def clean(self) -> None:
|
|
666
660
|
"""
|
|
@@ -722,7 +716,7 @@ class Model(metaclass=ModelBase):
|
|
|
722
716
|
lookup_kwargs = {}
|
|
723
717
|
for field_name in unique_check:
|
|
724
718
|
f = self._model_meta.get_forward_field(field_name)
|
|
725
|
-
lookup_value =
|
|
719
|
+
lookup_value = f.value_from_object(self)
|
|
726
720
|
if lookup_value is None:
|
|
727
721
|
# no value, skip the lookup
|
|
728
722
|
continue
|
|
@@ -842,7 +836,7 @@ class Model(metaclass=ModelBase):
|
|
|
842
836
|
for f in self._model_meta.fields:
|
|
843
837
|
if f.name in exclude:
|
|
844
838
|
continue
|
|
845
|
-
if self.__dict__.get(f.
|
|
839
|
+
if self.__dict__.get(f.name) is DATABASE_DEFAULT:
|
|
846
840
|
exclude.add(f.name)
|
|
847
841
|
elif f.auto_fills_on_save:
|
|
848
842
|
exclude.add(f.name)
|
|
@@ -896,11 +890,11 @@ class Model(metaclass=ModelBase):
|
|
|
896
890
|
continue
|
|
897
891
|
# Skip validation for empty fields with required=False. The developer
|
|
898
892
|
# is responsible for making sure they have a valid value.
|
|
899
|
-
raw_value =
|
|
893
|
+
raw_value = f.value_from_object(self)
|
|
900
894
|
if not f.required and raw_value in f.empty_values:
|
|
901
895
|
continue
|
|
902
896
|
try:
|
|
903
|
-
setattr(self, f.
|
|
897
|
+
setattr(self, f.name, f.clean(raw_value, self))
|
|
904
898
|
except ValidationError as e:
|
|
905
899
|
errors[f.name] = e.error_list
|
|
906
900
|
|
|
@@ -1000,12 +994,12 @@ class Model(metaclass=ModelBase):
|
|
|
1000
994
|
|
|
1001
995
|
@classmethod
|
|
1002
996
|
def _check_field_name_clashes(cls) -> list[PreflightResult]:
|
|
1003
|
-
"""Reject fields that share a name
|
|
997
|
+
"""Reject fields that share a name within the same model."""
|
|
1004
998
|
errors: list[PreflightResult] = []
|
|
1005
|
-
used_fields = {} # name
|
|
999
|
+
used_fields = {} # name -> field
|
|
1006
1000
|
|
|
1007
1001
|
for f in cls._model_meta.local_fields:
|
|
1008
|
-
clash = used_fields.get(f.name)
|
|
1002
|
+
clash = used_fields.get(f.name)
|
|
1009
1003
|
# Note that we may detect clash between user-defined non-unique
|
|
1010
1004
|
# field "id" and automatically added unique field "id", both
|
|
1011
1005
|
# defined at the same model. This special case is considered in
|
|
@@ -1023,7 +1017,6 @@ class Model(metaclass=ModelBase):
|
|
|
1023
1017
|
)
|
|
1024
1018
|
)
|
|
1025
1019
|
used_fields[f.name] = f
|
|
1026
|
-
used_fields[f.attname] = f
|
|
1027
1020
|
|
|
1028
1021
|
return errors
|
|
1029
1022
|
|
|
@@ -1082,7 +1075,7 @@ class Model(metaclass=ModelBase):
|
|
|
1082
1075
|
errors: list[PreflightResult] = []
|
|
1083
1076
|
property_names = cls._model_meta._property_names
|
|
1084
1077
|
related_field_accessors = (
|
|
1085
|
-
f.
|
|
1078
|
+
f.name
|
|
1086
1079
|
for f in cls._model_meta._get_fields(reverse=False)
|
|
1087
1080
|
if isinstance(f, RelatedField)
|
|
1088
1081
|
)
|
|
@@ -1164,8 +1157,6 @@ class Model(metaclass=ModelBase):
|
|
|
1164
1157
|
forward_fields_map: dict[str, Field] = {}
|
|
1165
1158
|
for field in cls._model_meta._get_fields(reverse=False):
|
|
1166
1159
|
forward_fields_map[field.name] = field
|
|
1167
|
-
if hasattr(field, "attname"):
|
|
1168
|
-
forward_fields_map[field.attname] = field
|
|
1169
1160
|
|
|
1170
1161
|
errors: list[PreflightResult] = []
|
|
1171
1162
|
for field_name in fields:
|
|
@@ -1277,7 +1268,7 @@ class Model(metaclass=ModelBase):
|
|
|
1277
1268
|
meta = cls._model_meta
|
|
1278
1269
|
valid_fields = set(
|
|
1279
1270
|
chain.from_iterable(
|
|
1280
|
-
(f.name,
|
|
1271
|
+
(f.name,)
|
|
1281
1272
|
if not (f.auto_created and not f.concrete)
|
|
1282
1273
|
else (f.field.related_query_name(),)
|
|
1283
1274
|
for f in chain(meta.fields, meta.related_objects)
|
|
@@ -363,7 +363,7 @@ class UniqueConstraint(BaseConstraint):
|
|
|
363
363
|
if exclude and field_name in exclude:
|
|
364
364
|
return
|
|
365
365
|
field = model._model_meta.get_forward_field(field_name)
|
|
366
|
-
lookup_value =
|
|
366
|
+
lookup_value = field.value_from_object(instance)
|
|
367
367
|
if lookup_value is None:
|
|
368
368
|
# A composite constraint containing NULL value cannot cause
|
|
369
369
|
# a violation since NULL != NULL in SQL.
|
|
@@ -82,15 +82,12 @@ def _load_field(
|
|
|
82
82
|
|
|
83
83
|
# A guide to Field parameters:
|
|
84
84
|
#
|
|
85
|
-
# * name: The name of the field specified in the model.
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
# * column: The database column for this field.
|
|
90
|
-
# "
|
|
91
|
-
#
|
|
92
|
-
# Code that introspects values, or does other dynamic things, should use
|
|
93
|
-
# attname.
|
|
85
|
+
# * name: The name of the field specified in the model. This is also the
|
|
86
|
+
# attribute and instance __dict__ key for the field's value -- a
|
|
87
|
+
# foreign key stores its raw key under the field name and is
|
|
88
|
+
# reached through its descriptor.
|
|
89
|
+
# * column: The database column for this field. The same as "name",
|
|
90
|
+
# except for ForeignKeys, where the "_id" suffix is appended.
|
|
94
91
|
|
|
95
92
|
|
|
96
93
|
def _empty(of_cls: type) -> Empty:
|
|
@@ -115,7 +112,6 @@ class Field[T](RegisterLookupMixin):
|
|
|
115
112
|
# Set by __init__
|
|
116
113
|
name: str | None
|
|
117
114
|
# Set by set_attributes_from_name (called by contribute_to_class)
|
|
118
|
-
attname: str
|
|
119
115
|
column: str
|
|
120
116
|
concrete: bool
|
|
121
117
|
# Set by contribute_to_class
|
|
@@ -376,8 +372,9 @@ class Field[T](RegisterLookupMixin):
|
|
|
376
372
|
|
|
377
373
|
def set_attributes_from_name(self, name: str) -> None:
|
|
378
374
|
self.name = self.name or name
|
|
379
|
-
|
|
380
|
-
|
|
375
|
+
# The database column is the field name. A foreign key overrides this
|
|
376
|
+
# method only to append the "_id" suffix to `column`.
|
|
377
|
+
self.column = self.name
|
|
381
378
|
self.concrete = self.column is not None
|
|
382
379
|
|
|
383
380
|
def contribute_to_class(self, cls: type[Model], name: str) -> None:
|
|
@@ -394,7 +391,8 @@ class Field[T](RegisterLookupMixin):
|
|
|
394
391
|
# Field is its own descriptor; make sure it is set on the class so
|
|
395
392
|
# attribute access hits __get__/__set__.
|
|
396
393
|
if self.column:
|
|
397
|
-
|
|
394
|
+
assert self.name is not None
|
|
395
|
+
setattr(cls, self.name, self)
|
|
398
396
|
|
|
399
397
|
# Descriptor protocol implementation
|
|
400
398
|
@overload
|
|
@@ -417,17 +415,25 @@ class Field[T](RegisterLookupMixin):
|
|
|
417
415
|
|
|
418
416
|
# If field hasn't been contributed to a class yet (e.g., used standalone
|
|
419
417
|
# as an output_field in aggregates), just return self
|
|
420
|
-
if not hasattr(self, "
|
|
418
|
+
if not hasattr(self, "column"):
|
|
421
419
|
return self
|
|
422
420
|
|
|
423
421
|
# Instance access - get value from instance dict
|
|
422
|
+
assert self.name is not None
|
|
424
423
|
data = instance.__dict__
|
|
425
|
-
field_name = self.
|
|
424
|
+
field_name = self.name
|
|
426
425
|
|
|
427
|
-
# If value not in dict, lazy load from database
|
|
426
|
+
# If value not in dict, lazy load from database. Hydrate every
|
|
427
|
+
# currently-missing concrete field in one query rather than one query
|
|
428
|
+
# per field accessed -- this is what makes a foreign key partial
|
|
429
|
+
# instance cheap to use beyond its primary key.
|
|
428
430
|
if field_name not in data:
|
|
429
|
-
|
|
430
|
-
|
|
431
|
+
missing = [
|
|
432
|
+
f.name
|
|
433
|
+
for f in instance._model_meta.concrete_fields
|
|
434
|
+
if f.name not in data
|
|
435
|
+
]
|
|
436
|
+
instance.refresh_from_db(fields=missing)
|
|
431
437
|
|
|
432
438
|
return cast(T, data.get(field_name))
|
|
433
439
|
|
|
@@ -436,10 +442,10 @@ class Field[T](RegisterLookupMixin):
|
|
|
436
442
|
Descriptor __set__ for attribute assignment.
|
|
437
443
|
|
|
438
444
|
Validates and converts the value using to_python(), then stores it
|
|
439
|
-
in instance.__dict__[
|
|
445
|
+
in instance.__dict__[name].
|
|
440
446
|
"""
|
|
441
447
|
# Safety check: ensure field has been properly initialized
|
|
442
|
-
if not hasattr(self, "
|
|
448
|
+
if not hasattr(self, "column"):
|
|
443
449
|
raise AttributeError(
|
|
444
450
|
f"Field {self.__class__.__name__} has not been initialized properly. "
|
|
445
451
|
f"The field's contribute_to_class() has not been called yet. "
|
|
@@ -452,7 +458,8 @@ class Field[T](RegisterLookupMixin):
|
|
|
452
458
|
value = self.to_python(value)
|
|
453
459
|
|
|
454
460
|
# Store in instance dict
|
|
455
|
-
|
|
461
|
+
assert self.name is not None
|
|
462
|
+
instance.__dict__[self.name] = value
|
|
456
463
|
|
|
457
464
|
def __delete__(self, instance: Model) -> None:
|
|
458
465
|
"""
|
|
@@ -460,20 +467,18 @@ class Field[T](RegisterLookupMixin):
|
|
|
460
467
|
|
|
461
468
|
Removes the value from instance.__dict__.
|
|
462
469
|
"""
|
|
470
|
+
assert self.name is not None
|
|
463
471
|
try:
|
|
464
|
-
del instance.__dict__[self.
|
|
472
|
+
del instance.__dict__[self.name]
|
|
465
473
|
except KeyError:
|
|
466
474
|
raise AttributeError(
|
|
467
|
-
f"{instance.__class__.__name__!r} object has no attribute {self.
|
|
475
|
+
f"{instance.__class__.__name__!r} object has no attribute {self.name!r}"
|
|
468
476
|
)
|
|
469
477
|
|
|
470
|
-
def get_attname(self) -> str:
|
|
471
|
-
assert self.name is not None # Field name must be set
|
|
472
|
-
return self.name
|
|
473
|
-
|
|
474
478
|
def pre_save(self, model_instance: Model, add: bool) -> T | None:
|
|
475
479
|
"""Return field's value just before saving."""
|
|
476
|
-
|
|
480
|
+
assert self.name is not None
|
|
481
|
+
return getattr(model_instance, self.name)
|
|
477
482
|
|
|
478
483
|
def get_prep_value(self, value: Any) -> Any:
|
|
479
484
|
"""Perform preliminary non-db specific value checks and conversions."""
|
|
@@ -530,7 +535,8 @@ class Field[T](RegisterLookupMixin):
|
|
|
530
535
|
|
|
531
536
|
def value_from_object(self, obj: Model) -> T | None:
|
|
532
537
|
"""Return the value of this field in the given model instance."""
|
|
533
|
-
|
|
538
|
+
assert self.name is not None
|
|
539
|
+
return getattr(obj, self.name)
|
|
534
540
|
|
|
535
541
|
|
|
536
542
|
class ColumnField[T](Field[T]):
|