plain.postgres 0.112.0__tar.gz → 0.113.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.112.0 → plain_postgres-0.113.0}/PKG-INFO +4 -3
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/CHANGELOG.md +19 -1
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/README.md +3 -2
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +1 -1
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/cli/sync.py +1 -1
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/expressions.py +3 -5
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/base.py +53 -18
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/binary.py +5 -4
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/encrypted.py +56 -104
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/json.py +12 -5
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/related.py +8 -30
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/related_managers.py +8 -4
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/reverse_related.py +3 -9
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/forms.py +4 -11
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/lookups.py +2 -2
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/autodetector.py +26 -16
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/query.py +4 -87
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/query_utils.py +6 -50
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/sql/__init__.py +1 -2
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/sql/compiler.py +15 -133
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/sql/datastructures.py +1 -36
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/sql/query.py +26 -284
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/sql/where.py +4 -42
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/types.pyi +36 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/pyproject.toml +1 -1
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/models/encrypted.py +1 -1
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_autodetector_not_null_errors.py +86 -42
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_literal_default_persistence.py +42 -4
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_schema_normalize_type.py +7 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_encrypted_fields.py +13 -3
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_m2m.py +37 -0
- plain_postgres-0.113.0/tests/public/test_only_empty_defaults.py +76 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/.gitignore +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/CLAUDE.md +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/LICENSE +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/README.md +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/__init__.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/adapters.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/base.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/cli/decorators.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/connection.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/constraints.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/convergence/analysis.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/convergence/corrections.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/convergence/planning.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/databases.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/temporal.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/fields/uuid.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/introspection/health/__init__.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/introspection/health/context.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/introspection/health/helpers.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/introspection/health/ownership.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/introspection/health/runner.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/introspection/health/types.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/introspection/schema.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/middleware.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/preflight/__init__.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/preflight/database.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/preflight/indexes.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/preflight/models.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/schema_lock.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/sources.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/test/database.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/models/__init__.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/models/constraints.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/models/defaults.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/models/delete.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/models/forms.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/models/indexes.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/models/iteration.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/models/mixins.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/models/nullability.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/models/querysets.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/models/relationships.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/models/storage_parameters.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/models/trees.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/conftest.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_apply_replan.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_connection_isolation.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_connection_pool.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_connection_self_heal.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_constraint_violation_error.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_convergence.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_convergence_constraints.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_convergence_defaults.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_convergence_fk.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_convergence_indexes.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_convergence_nullability.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_convergence_timeouts.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_databases_not_on_runtime_path.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_db_expression_defaults.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_diagnose.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_executor_connection_hook.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_fk_characterization.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_health.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_introspection.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_management_connection.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_migration_executor.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_otel_metrics.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_rollback_exc_attribution.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_schema_lock.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/internal/test_schema_timeouts.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_create_update.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_database_url.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_databases.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_deferred_loading.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_delete_behaviors.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_exceptions.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_field_defaults.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_functions_uuid.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_integrity_error_mapping.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_iterator.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_manager_assignment.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_manual_pk.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_mixins.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_modelform_roundtrip.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_queryset_repr.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_queryset_slicing.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_random_string_field.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_raw_query.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_read_only_transactions.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_related.py +0 -0
- {plain_postgres-0.112.0 → plain_postgres-0.113.0}/tests/public/test_related_instance_filter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plain.postgres
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.113.0
|
|
4
4
|
Summary: Model your data and store it in a database.
|
|
5
5
|
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -941,6 +941,7 @@ Values are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) with a key derived
|
|
|
941
941
|
|
|
942
942
|
- **No lookups** — encrypted values are non-deterministic (same plaintext produces different ciphertext each time), so filtering on encrypted fields doesn't work. Only `isnull` lookups are supported.
|
|
943
943
|
- **No indexes or constraints** — encrypted fields cannot be used in indexes or unique constraints. Preflight checks will catch this.
|
|
944
|
+
- **Only `default=""`** — on `EncryptedTextField` (paired with `required=False`), the empty string is stored as plaintext `''`, so it's the one value expressible as a column `DEFAULT` (declare it to add the field to a populated table). Any other default would need ciphertext, which is non-deterministic. `EncryptedJSONField` accepts no default at all — even `{}` serializes to text that would need ciphertext; use `allow_null=True`.
|
|
944
945
|
|
|
945
946
|
**Key rotation:**
|
|
946
947
|
|
|
@@ -1492,9 +1493,9 @@ See [`default_settings.py`](./default_settings.py) for more details.
|
|
|
1492
1493
|
|
|
1493
1494
|
Add the field to your model class, then run `plain migrations create` to create a migration.
|
|
1494
1495
|
|
|
1495
|
-
If the field
|
|
1496
|
+
If the field has no `default=` and isn't `allow_null=True`, the autodetector refuses to generate the migration, since there's no value to seed existing rows with — `required=False` alone is not enough (it only affects Python-side validation, not the column). You have two options:
|
|
1496
1497
|
|
|
1497
|
-
1. Declare a `default=` on the field so the new column has a value for existing rows.
|
|
1498
|
+
1. Declare a `default=` on the field so the new column has a value for existing rows. For an optional string field the idiom is `required=False, default=""` (for `BinaryField`, `default=b""`).
|
|
1498
1499
|
2. Add the field with `allow_null=True`, scaffold a data migration with `plain migrations create --empty --name backfill_<field>` to populate existing rows, then remove `allow_null=True` from the field — convergence applies `NOT NULL` on the next `postgres sync`.
|
|
1499
1500
|
|
|
1500
1501
|
#### How do I make an existing column `NOT NULL`?
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
-
## [0.
|
|
3
|
+
## [0.113.0](https://github.com/dropseed/plain/releases/plain-postgres@0.113.0) (2026-08-02)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- **`QuerySet.extra()` has been removed**, along with all of its supporting machinery (`extra_select`, `extra_tables`, `extra_order_by`, `ExtraWhere`). Use `annotate()` with `RawSQL` or other expressions instead. ([3fed0b32f1](https://github.com/dropseed/plain/commit/3fed0b32f1))
|
|
8
|
+
- **`FilteredRelation` has been removed** — it's no longer importable from `plain.postgres` or usable in `annotate()`. Express conditional joins with `Q` filters or subqueries instead. ([434f57852f](https://github.com/dropseed/plain/commit/434f57852f))
|
|
9
|
+
- **`QuerySet.alias()` and logical XOR have been removed** — `Q(...) ^ Q(...)` and `queryset ^ queryset` are no longer supported. Use `annotate()` in place of `alias()`, and compose AND/OR/NOT in place of XOR. ([c12cf81af4](https://github.com/dropseed/plain/commit/c12cf81af4))
|
|
10
|
+
- **Encrypted and binary fields now accept their empty value as a declared `default=`.** `EncryptedTextField` (now a `TextField` subclass) accepts exactly `default=""` and `BinaryField` accepts exactly `default=b""`, each paired with `required=False` — the empty value is stored as plaintext so it's the one default expressible as a column `DEFAULT`, which is what lets the field be added to a populated table. `default=None` requires `allow_null=True` + `required=False`. The `types.pyi` stub overloads encode the same coupling. ([2a86968e5a](https://github.com/dropseed/plain/commit/2a86968e5a))
|
|
11
|
+
- **The migration autodetector no longer waves through `required=False` fields with no declared default.** Adding a non-nullable field without a `default=` now refuses at `plain migrations create` time with a field-accurate remedy (previously these could generate an `AddField` that failed at apply time on any populated table). The optional-string idiom is `required=False, default=""`. ([2a86968e5a](https://github.com/dropseed/plain/commit/2a86968e5a))
|
|
12
|
+
- **`EncryptedJSONField` is now a `JSONField` subclass** and `EncryptedTextField` a `TextField` subclass, instead of parallel `ColumnField` implementations — behavior like max_length validation and JSON encoding now comes from the real base classes. The encrypted lookup surface is still just `exact`/`isnull`, and JSON key transforms are blocked (they'd operate on ciphertext). `EncryptedJSONField` accepts no `default=` at all — even `{}` would need ciphertext; use `allow_null=True`. ([5153ceb106](https://github.com/dropseed/plain/commit/5153ceb106), [2a86968e5a](https://github.com/dropseed/plain/commit/2a86968e5a))
|
|
13
|
+
- Model forms no longer prefill a `BinaryField`'s `b""` default as a text input's initial value (it rendered as the literal `b''`), and encrypted fields now map to form fields through the regular `TextField`/`JSONField` branches. ([2a86968e5a](https://github.com/dropseed/plain/commit/2a86968e5a))
|
|
14
|
+
- M2M prefetches now expose the through-table join column via `annotate(RawSQL(...))` instead of the removed `extra()`, and internal path-info plumbing lost its `filtered_relation` threading. ([3fed0b32f1](https://github.com/dropseed/plain/commit/3fed0b32f1), [434f57852f](https://github.com/dropseed/plain/commit/434f57852f))
|
|
15
|
+
|
|
16
|
+
### Upgrade instructions
|
|
17
|
+
|
|
18
|
+
- Replace `QuerySet.extra()` calls: `extra(select=...)` becomes `annotate(name=RawSQL("...", []))`, `extra(where=...)` becomes `filter()` with expressions or `RawSQL`, and `extra(order_by=...)` becomes `order_by()` on an annotation.
|
|
19
|
+
- Replace `FilteredRelation` annotations with filtered subqueries or `Q` conditions, and `QuerySet.alias()` with `annotate()`.
|
|
20
|
+
- Replace `Q(a) ^ Q(b)` / `qs1 ^ qs2` with explicit AND/OR logic: `(Q(a) | Q(b)) & ~(Q(a) & Q(b))`.
|
|
21
|
+
- If `plain migrations create` now refuses a field it previously accepted, follow the error's remedy: declare `required=False, default=""` (or `default=b""` / an appropriate literal) so existing rows get a value, or add it with `allow_null=True` plus a backfill data migration.(https://github.com/dropseed/plain/releases/plain-postgres@0.112.0) (2026-07-21)
|
|
4
22
|
|
|
5
23
|
### What's changed
|
|
6
24
|
|
|
@@ -928,6 +928,7 @@ Values are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) with a key derived
|
|
|
928
928
|
|
|
929
929
|
- **No lookups** — encrypted values are non-deterministic (same plaintext produces different ciphertext each time), so filtering on encrypted fields doesn't work. Only `isnull` lookups are supported.
|
|
930
930
|
- **No indexes or constraints** — encrypted fields cannot be used in indexes or unique constraints. Preflight checks will catch this.
|
|
931
|
+
- **Only `default=""`** — on `EncryptedTextField` (paired with `required=False`), the empty string is stored as plaintext `''`, so it's the one value expressible as a column `DEFAULT` (declare it to add the field to a populated table). Any other default would need ciphertext, which is non-deterministic. `EncryptedJSONField` accepts no default at all — even `{}` serializes to text that would need ciphertext; use `allow_null=True`.
|
|
931
932
|
|
|
932
933
|
**Key rotation:**
|
|
933
934
|
|
|
@@ -1479,9 +1480,9 @@ See [`default_settings.py`](./default_settings.py) for more details.
|
|
|
1479
1480
|
|
|
1480
1481
|
Add the field to your model class, then run `plain migrations create` to create a migration.
|
|
1481
1482
|
|
|
1482
|
-
If the field
|
|
1483
|
+
If the field has no `default=` and isn't `allow_null=True`, the autodetector refuses to generate the migration, since there's no value to seed existing rows with — `required=False` alone is not enough (it only affects Python-side validation, not the column). You have two options:
|
|
1483
1484
|
|
|
1484
|
-
1. Declare a `default=` on the field so the new column has a value for existing rows.
|
|
1485
|
+
1. Declare a `default=` on the field so the new column has a value for existing rows. For an optional string field the idiom is `required=False, default=""` (for `BinaryField`, `default=b""`).
|
|
1485
1486
|
2. Add the field with `allow_null=True`, scaffold a data migration with `plain migrations create --empty --name backfill_<field>` to populate existing rows, then remove `allow_null=True` from the field — convergence applies `NOT NULL` on the next `postgres sync`.
|
|
1486
1487
|
|
|
1487
1488
|
#### How do I make an existing column `NOT NULL`?
|
|
@@ -79,7 +79,7 @@ Run `uv run plain docs postgres` for full patterns with code examples.
|
|
|
79
79
|
- Indexes: `{table}_{column(s)}_idx`
|
|
80
80
|
- Constraints: `{table}_{column(s)}_{type}` (e.g., `_unique`, `_check`)
|
|
81
81
|
- Choose `on_delete` deliberately: CASCADE for owned children, RESTRICT for referenced data, SET_NULL for optional references
|
|
82
|
-
- No `allow_null` on string fields — use `default=""`
|
|
82
|
+
- No `allow_null` on string fields — use `default=""`. Optional string fields are `required=False, default=""` — the declared default is what lets the column be added to a populated table (`required=False` alone only affects Python-side validation)
|
|
83
83
|
|
|
84
84
|
Run `uv run plain docs postgres` for full patterns with code examples.
|
|
85
85
|
|
|
@@ -7,6 +7,7 @@ import click
|
|
|
7
7
|
from plain.runtime import settings
|
|
8
8
|
|
|
9
9
|
from ..convergence import execute_plan, plan_convergence
|
|
10
|
+
from ..db import get_connection
|
|
10
11
|
from .decorators import cli_schema_lock, database_management_command
|
|
11
12
|
|
|
12
13
|
|
|
@@ -112,7 +113,6 @@ def _create_migrations() -> None:
|
|
|
112
113
|
|
|
113
114
|
|
|
114
115
|
def _migrate() -> None:
|
|
115
|
-
from ..db import get_connection
|
|
116
116
|
from ..migrations.executor import MigrationExecutor
|
|
117
117
|
|
|
118
118
|
click.secho("Applying migrations...", bold=True)
|
|
@@ -163,9 +163,7 @@ class Combinable:
|
|
|
163
163
|
def bitrightshift(self, other: Any) -> CombinedExpression:
|
|
164
164
|
return self._combine(other, self.BITRIGHTSHIFT, False)
|
|
165
165
|
|
|
166
|
-
def __xor__(self, other: Any) ->
|
|
167
|
-
if getattr(self, "conditional", False) and getattr(other, "conditional", False):
|
|
168
|
-
return Q(self) ^ Q(other)
|
|
166
|
+
def __xor__(self, other: Any) -> None:
|
|
169
167
|
raise NotImplementedError(
|
|
170
168
|
"Use .bitand(), .bitor(), and .bitxor() for bitwise logical operations."
|
|
171
169
|
)
|
|
@@ -431,8 +429,8 @@ class BaseExpression:
|
|
|
431
429
|
def get_lookup(self, lookup: str) -> type[Lookup] | None:
|
|
432
430
|
return self.output_field.get_lookup(lookup)
|
|
433
431
|
|
|
434
|
-
def get_transform(self, name: str) ->
|
|
435
|
-
return self.output_field.get_transform(name)
|
|
432
|
+
def get_transform(self, name: str) -> Callable[..., Transform] | None:
|
|
433
|
+
return self.output_field.get_transform(name)
|
|
436
434
|
|
|
437
435
|
def relabeled_clone(self, change_map: dict[str, str]) -> Self:
|
|
438
436
|
clone = self.copy()
|
|
@@ -516,8 +516,8 @@ class Field[T](RegisterLookupMixin):
|
|
|
516
516
|
return self.get_db_prep_value(value, connection=connection, prepared=False)
|
|
517
517
|
|
|
518
518
|
# Empty-value fallback used by ColumnField.get_default for the
|
|
519
|
-
# not-null +
|
|
520
|
-
#
|
|
519
|
+
# not-null + empty_strings_allowed case (Python-side Model()
|
|
520
|
+
# construction). BinaryField overrides with b"".
|
|
521
521
|
_default_empty_value: Any = ""
|
|
522
522
|
|
|
523
523
|
def has_default(self) -> bool:
|
|
@@ -666,6 +666,15 @@ class DefaultableField[T](ColumnField[T]):
|
|
|
666
666
|
|
|
667
667
|
non_migration_attrs = (*ColumnField.non_migration_attrs, "default")
|
|
668
668
|
|
|
669
|
+
# Subclasses whose __init__ deliberately doesn't take default=
|
|
670
|
+
# (EncryptedJSONField) set this False so callers like the autodetector's
|
|
671
|
+
# error guidance don't suggest a kwarg that would raise.
|
|
672
|
+
accepts_default = True
|
|
673
|
+
|
|
674
|
+
# Subclasses set this when their only expressible column DEFAULT is the
|
|
675
|
+
# class's `_default_empty_value` ("" / b"").
|
|
676
|
+
only_empty_default = False
|
|
677
|
+
|
|
669
678
|
def __init__(
|
|
670
679
|
self,
|
|
671
680
|
*,
|
|
@@ -674,22 +683,48 @@ class DefaultableField[T](ColumnField[T]):
|
|
|
674
683
|
allow_null: bool = False,
|
|
675
684
|
validators: Sequence[Callable[..., Any]] = (),
|
|
676
685
|
):
|
|
677
|
-
if default is not NOT_PROVIDED
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
686
|
+
if default is not NOT_PROVIDED:
|
|
687
|
+
if callable(default):
|
|
688
|
+
raise TypeError(
|
|
689
|
+
f"{type(self).__name__}(default=...) must be a static literal. "
|
|
690
|
+
f"For empty collections pass default={{}} or default=[]; for "
|
|
691
|
+
f"per-row generation use a DB-side expression "
|
|
692
|
+
f"(create_now=True, generate=True, RandomStringField)."
|
|
693
|
+
)
|
|
694
|
+
if isinstance(default, str) and "\\" in default:
|
|
695
|
+
# psycopg quotes backslash-bearing strings with `E'...'` escape
|
|
696
|
+
# syntax, but pg_get_expr returns the stored DEFAULT as a standard
|
|
697
|
+
# `'...'` literal — the two forms don't compare lexically, so
|
|
698
|
+
# convergence would flag spurious drift on every sync. Reject at
|
|
699
|
+
# declaration time rather than ship a sync that never converges.
|
|
700
|
+
raise ValueError(
|
|
701
|
+
f"{type(self).__name__}(default=...) must not contain a backslash."
|
|
702
|
+
)
|
|
703
|
+
if self.only_empty_default:
|
|
704
|
+
empty = self._default_empty_value
|
|
705
|
+
if default is None:
|
|
706
|
+
if not allow_null or required:
|
|
707
|
+
raise ValueError(
|
|
708
|
+
f"{type(self).__name__} with default=None must also "
|
|
709
|
+
f"set allow_null=True and required=False — the "
|
|
710
|
+
f"column must accept NULL, and required=True would "
|
|
711
|
+
f"reject the defaulted None on every save."
|
|
712
|
+
)
|
|
713
|
+
# `type()` (not isinstance/==) so bytearray()/memoryview(b"")
|
|
714
|
+
# can't slip through — they compare equal to b"" but don't
|
|
715
|
+
# deepcopy or serialize like it.
|
|
716
|
+
elif type(default) is not type(empty) or default != empty:
|
|
717
|
+
raise ValueError(
|
|
718
|
+
f"{type(self).__name__} only supports default={empty!r} — "
|
|
719
|
+
f"the empty value, used to backfill existing rows when "
|
|
720
|
+
f"the field is added to a populated table."
|
|
721
|
+
)
|
|
722
|
+
elif required:
|
|
723
|
+
raise ValueError(
|
|
724
|
+
f"{type(self).__name__} with default={empty!r} must also "
|
|
725
|
+
f"set required=False — the default fills the field with "
|
|
726
|
+
f"an empty value that required=True then rejects."
|
|
727
|
+
)
|
|
693
728
|
self.default = default
|
|
694
729
|
super().__init__(
|
|
695
730
|
required=required,
|
|
@@ -8,7 +8,7 @@ import psycopg
|
|
|
8
8
|
|
|
9
9
|
from plain.validators import MaxLengthValidator
|
|
10
10
|
|
|
11
|
-
from .base import
|
|
11
|
+
from .base import NOT_PROVIDED, DefaultableField
|
|
12
12
|
|
|
13
13
|
if TYPE_CHECKING:
|
|
14
14
|
from plain.postgres.connection import DatabaseConnection
|
|
@@ -17,10 +17,11 @@ if TYPE_CHECKING:
|
|
|
17
17
|
|
|
18
18
|
class BinaryField[
|
|
19
19
|
T: (bytes | memoryview, bytes | memoryview | None) = bytes | memoryview
|
|
20
|
-
](
|
|
20
|
+
](DefaultableField[T]):
|
|
21
21
|
db_type_sql = "bytea"
|
|
22
22
|
empty_values = [None, b""]
|
|
23
23
|
_default_empty_value = b""
|
|
24
|
+
only_empty_default = True
|
|
24
25
|
|
|
25
26
|
def __init__(
|
|
26
27
|
self,
|
|
@@ -28,14 +29,14 @@ class BinaryField[
|
|
|
28
29
|
max_length: int | None = None,
|
|
29
30
|
required: bool = True,
|
|
30
31
|
allow_null: bool = False,
|
|
32
|
+
default: Any = NOT_PROVIDED,
|
|
31
33
|
validators: Sequence[Callable[..., Any]] = (),
|
|
32
34
|
):
|
|
33
|
-
# `default` is intentionally not accepted: a str default on a bytes
|
|
34
|
-
# field is a type mismatch.
|
|
35
35
|
self.max_length = max_length
|
|
36
36
|
super().__init__(
|
|
37
37
|
required=required,
|
|
38
38
|
allow_null=allow_null,
|
|
39
|
+
default=default,
|
|
39
40
|
validators=validators,
|
|
40
41
|
)
|
|
41
42
|
if self.max_length is not None:
|
|
@@ -16,11 +16,14 @@ except ImportError:
|
|
|
16
16
|
hashes = None # ty: ignore[invalid-assignment]
|
|
17
17
|
PBKDF2HMAC = None # ty: ignore[invalid-assignment]
|
|
18
18
|
|
|
19
|
-
from plain import
|
|
19
|
+
from plain import preflight
|
|
20
|
+
from plain.postgres.lookups import Exact, IsNull
|
|
20
21
|
from plain.runtime import settings
|
|
21
22
|
from plain.utils.encoding import force_bytes
|
|
22
23
|
|
|
23
|
-
from .base import
|
|
24
|
+
from .base import NOT_PROVIDED
|
|
25
|
+
from .json import JSONField
|
|
26
|
+
from .text import TextField
|
|
24
27
|
|
|
25
28
|
if TYPE_CHECKING:
|
|
26
29
|
from collections.abc import Callable, Sequence
|
|
@@ -104,19 +107,12 @@ def _decrypt(value: str) -> str:
|
|
|
104
107
|
)
|
|
105
108
|
|
|
106
109
|
|
|
107
|
-
# isnull is obviously needed. exact is required so that `filter(field=None)`
|
|
108
|
-
# works — the ORM resolves "exact" first and then rewrites None to isnull.
|
|
109
|
-
# Exact lookups on non-None values will silently return no results (since
|
|
110
|
-
# ciphertext is non-deterministic), but blocking exact entirely would break
|
|
111
|
-
# the None/isnull path.
|
|
112
|
-
_ALLOWED_LOOKUPS = {"isnull", "exact"}
|
|
113
|
-
|
|
114
|
-
|
|
115
110
|
class EncryptedFieldMixin:
|
|
116
111
|
"""Shared behavior for all encrypted fields.
|
|
117
112
|
|
|
118
|
-
|
|
119
|
-
|
|
113
|
+
Owns the lookup surface (isnull and exact only — ciphertext is
|
|
114
|
+
non-deterministic) and the preflight that blocks indexes and unique
|
|
115
|
+
constraints.
|
|
120
116
|
|
|
121
117
|
Must be used with Field as a co-base class.
|
|
122
118
|
"""
|
|
@@ -125,17 +121,32 @@ class EncryptedFieldMixin:
|
|
|
125
121
|
name: str
|
|
126
122
|
model: Any
|
|
127
123
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
124
|
+
# The complete lookup surface, replacing the base field's registry.
|
|
125
|
+
# isnull is obviously needed. exact is required so that `filter(field=None)`
|
|
126
|
+
# works — the ORM resolves "exact" first and then rewrites None to isnull.
|
|
127
|
+
# Exact lookups on non-None values will silently return no results (since
|
|
128
|
+
# ciphertext is non-deterministic), but blocking exact entirely would break
|
|
129
|
+
# the None/isnull path. The base classes are named directly — inheriting
|
|
130
|
+
# the concrete field's registrations would leak specialized lookups like
|
|
131
|
+
# JSONField's JSONExact, which compares against the jsonb 'null' literal
|
|
132
|
+
# and defeats the None→isnull rewrite. get_lookup()/get_transform() and
|
|
133
|
+
# registry consumers (e.g. unsupported-lookup error suggestions) all
|
|
134
|
+
# resolve through this one dict. A classmethod so both class-level and
|
|
135
|
+
# instance-level callers work.
|
|
136
|
+
@classmethod
|
|
137
|
+
def get_lookups(cls) -> dict[str, type[Lookup | Transform]]:
|
|
138
|
+
return {"exact": Exact, "isnull": IsNull}
|
|
139
|
+
|
|
140
|
+
def get_transform(self, name: str) -> Callable[..., Transform] | None:
|
|
141
|
+
# JSONField's get_transform falls back to KeyTransformFactory for any
|
|
142
|
+
# name — key transforms would operate on ciphertext, so block them.
|
|
137
143
|
return None
|
|
138
144
|
|
|
145
|
+
def preflight(self, **kwargs: Any) -> list[PreflightResult]:
|
|
146
|
+
errors: list[PreflightResult] = super().preflight(**kwargs) # ty: ignore[unresolved-attribute]
|
|
147
|
+
errors.extend(self._check_encrypted_constraints())
|
|
148
|
+
return errors
|
|
149
|
+
|
|
139
150
|
def _check_encrypted_constraints(self) -> list[PreflightResult]:
|
|
140
151
|
errors: list[PreflightResult] = []
|
|
141
152
|
if not hasattr(self, "model"):
|
|
@@ -178,20 +189,21 @@ class EncryptedFieldMixin:
|
|
|
178
189
|
return errors
|
|
179
190
|
|
|
180
191
|
|
|
181
|
-
class EncryptedTextField[T: (str, str | None) = str](
|
|
182
|
-
|
|
183
|
-
):
|
|
184
|
-
"""A text field that encrypts its value before storing in the database.
|
|
192
|
+
class EncryptedTextField[T: (str, str | None) = str](EncryptedFieldMixin, TextField[T]):
|
|
193
|
+
"""A TextField that encrypts its value before storing in the database.
|
|
185
194
|
|
|
186
195
|
Values are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) with a key
|
|
187
196
|
derived from SECRET_KEY. The database column is always ``text`` regardless
|
|
188
197
|
of max_length, since ciphertext length is unpredictable.
|
|
189
198
|
|
|
190
199
|
max_length is enforced on the plaintext value (validation), not on the
|
|
191
|
-
ciphertext stored in the database.
|
|
200
|
+
ciphertext stored in the database. Only ``default=""`` (with
|
|
201
|
+
``required=False``) is accepted — empty strings are stored as plaintext
|
|
202
|
+
``''``, so the empty value is the one default expressible as a column
|
|
203
|
+
DEFAULT; anything else would need ciphertext, which is non-deterministic.
|
|
192
204
|
"""
|
|
193
205
|
|
|
194
|
-
|
|
206
|
+
only_empty_default = True
|
|
195
207
|
|
|
196
208
|
def __init__(
|
|
197
209
|
self,
|
|
@@ -199,40 +211,20 @@ class EncryptedTextField[T: (str, str | None) = str](
|
|
|
199
211
|
max_length: int | None = None,
|
|
200
212
|
required: bool = True,
|
|
201
213
|
allow_null: bool = False,
|
|
214
|
+
default: Any = NOT_PROVIDED,
|
|
202
215
|
validators: Sequence[Callable[..., Any]] = (),
|
|
203
216
|
):
|
|
204
|
-
#
|
|
205
|
-
# non-deterministic, so
|
|
206
|
-
|
|
217
|
+
# Deliberately narrower than TextField: no `choices` — exact lookups
|
|
218
|
+
# on ciphertext are non-deterministic, so choice-based filtering would
|
|
219
|
+
# silently match nothing.
|
|
207
220
|
super().__init__(
|
|
221
|
+
max_length=max_length,
|
|
208
222
|
required=required,
|
|
209
223
|
allow_null=allow_null,
|
|
224
|
+
default=default,
|
|
210
225
|
validators=validators,
|
|
211
226
|
)
|
|
212
227
|
|
|
213
|
-
def to_python(self, value: Any) -> str | None:
|
|
214
|
-
if isinstance(value, str) or value is None:
|
|
215
|
-
return value
|
|
216
|
-
return str(value)
|
|
217
|
-
|
|
218
|
-
def validate(self, value: Any, model_instance: Any) -> None:
|
|
219
|
-
super().validate(value, model_instance)
|
|
220
|
-
if (
|
|
221
|
-
self.max_length is not None
|
|
222
|
-
and value is not None
|
|
223
|
-
and len(value) > self.max_length
|
|
224
|
-
):
|
|
225
|
-
raise exceptions.ValidationError(
|
|
226
|
-
f"Ensure this value has at most {self.max_length} characters (it has {len(value)}).",
|
|
227
|
-
code="max_length",
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
def get_prep_value(self, value: Any) -> Any:
|
|
231
|
-
value = super().get_prep_value(value)
|
|
232
|
-
if value is None:
|
|
233
|
-
return value
|
|
234
|
-
return self.to_python(value)
|
|
235
|
-
|
|
236
228
|
def get_db_prep_value(
|
|
237
229
|
self, value: Any, connection: DatabaseConnection, prepared: bool = False
|
|
238
230
|
) -> Any:
|
|
@@ -248,19 +240,8 @@ class EncryptedTextField[T: (str, str | None) = str](
|
|
|
248
240
|
return value
|
|
249
241
|
return _decrypt(value)
|
|
250
242
|
|
|
251
|
-
def deconstruct(self) -> tuple[str | None, str, list[Any], dict[str, Any]]:
|
|
252
|
-
name, path, args, kwargs = super().deconstruct()
|
|
253
|
-
if self.max_length is not None:
|
|
254
|
-
kwargs["max_length"] = self.max_length
|
|
255
|
-
return name, path, args, kwargs
|
|
256
243
|
|
|
257
|
-
|
|
258
|
-
errors = super().preflight(**kwargs)
|
|
259
|
-
errors.extend(self._check_encrypted_constraints())
|
|
260
|
-
return errors
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
class EncryptedJSONField(EncryptedFieldMixin, ColumnField):
|
|
244
|
+
class EncryptedJSONField(EncryptedFieldMixin, JSONField):
|
|
264
245
|
"""A JSONField that encrypts its serialized value before storing in the database.
|
|
265
246
|
|
|
266
247
|
The JSON value is serialized to a string, encrypted, and stored as text.
|
|
@@ -268,7 +249,7 @@ class EncryptedJSONField(EncryptedFieldMixin, ColumnField):
|
|
|
268
249
|
"""
|
|
269
250
|
|
|
270
251
|
db_type_sql = "text"
|
|
271
|
-
|
|
252
|
+
accepts_default = False
|
|
272
253
|
|
|
273
254
|
def __init__(
|
|
274
255
|
self,
|
|
@@ -279,47 +260,23 @@ class EncryptedJSONField(EncryptedFieldMixin, ColumnField):
|
|
|
279
260
|
allow_null: bool = False,
|
|
280
261
|
validators: Sequence[Callable[..., Any]] = (),
|
|
281
262
|
):
|
|
282
|
-
#
|
|
283
|
-
#
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if decoder and not callable(decoder):
|
|
287
|
-
raise ValueError("The decoder parameter must be a callable object.")
|
|
288
|
-
self.encoder = encoder
|
|
289
|
-
self.decoder = decoder
|
|
263
|
+
# Deliberately narrower than JSONField: no `default` — there is no
|
|
264
|
+
# empty plaintext value (even {} serializes to text that would need
|
|
265
|
+
# ciphertext, which is non-deterministic), so no literal column
|
|
266
|
+
# DEFAULT can be expressed.
|
|
290
267
|
super().__init__(
|
|
268
|
+
encoder=encoder,
|
|
269
|
+
decoder=decoder,
|
|
291
270
|
required=required,
|
|
292
271
|
allow_null=allow_null,
|
|
293
272
|
validators=validators,
|
|
294
273
|
)
|
|
295
274
|
|
|
296
|
-
def
|
|
297
|
-
|
|
298
|
-
if self.encoder is not None:
|
|
299
|
-
kwargs["encoder"] = self.encoder
|
|
300
|
-
if self.decoder is not None:
|
|
301
|
-
kwargs["decoder"] = self.decoder
|
|
302
|
-
return name, path, args, kwargs
|
|
303
|
-
|
|
304
|
-
def validate(self, value: Any, model_instance: Any) -> None:
|
|
305
|
-
super().validate(value, model_instance)
|
|
306
|
-
try:
|
|
307
|
-
json.dumps(value, cls=self.encoder)
|
|
308
|
-
except TypeError:
|
|
309
|
-
raise exceptions.ValidationError(
|
|
310
|
-
"Value must be valid JSON.",
|
|
311
|
-
code="invalid",
|
|
312
|
-
params={"value": value},
|
|
313
|
-
)
|
|
314
|
-
|
|
315
|
-
def get_db_prep_value(
|
|
316
|
-
self, value: Any, connection: DatabaseConnection, prepared: bool = False
|
|
317
|
-
) -> Any:
|
|
318
|
-
value = super().get_db_prep_value(value, connection, prepared)
|
|
275
|
+
def adapt_json_db_value(self, value: Any) -> Any:
|
|
276
|
+
# jsonb adaptation would emit jsonb — this column stores ciphertext.
|
|
319
277
|
if value is None:
|
|
320
278
|
return value
|
|
321
|
-
|
|
322
|
-
return _encrypt(json_str)
|
|
279
|
+
return _encrypt(json.dumps(value, cls=self.encoder))
|
|
323
280
|
|
|
324
281
|
def from_db_value(
|
|
325
282
|
self, value: Any, expression: Any, connection: DatabaseConnection
|
|
@@ -334,8 +291,3 @@ class EncryptedJSONField(EncryptedFieldMixin, ColumnField):
|
|
|
334
291
|
"Encrypted field contains data that is not valid JSON. "
|
|
335
292
|
"The stored value may be corrupt."
|
|
336
293
|
)
|
|
337
|
-
|
|
338
|
-
def preflight(self, **kwargs: Any) -> list[PreflightResult]:
|
|
339
|
-
errors = super().preflight(**kwargs)
|
|
340
|
-
errors.extend(self._check_encrypted_constraints())
|
|
341
|
-
return errors
|
|
@@ -83,6 +83,15 @@ class JSONField(DefaultableField):
|
|
|
83
83
|
value = value.value
|
|
84
84
|
elif hasattr(value, "as_sql"):
|
|
85
85
|
return value
|
|
86
|
+
return self.adapt_json_db_value(value)
|
|
87
|
+
|
|
88
|
+
def adapt_json_db_value(self, value: Any) -> Any:
|
|
89
|
+
"""Adapt a plain JSON value for this field's column — the one step
|
|
90
|
+
subclasses with a different column type (EncryptedJSONField) replace.
|
|
91
|
+
|
|
92
|
+
May receive None (get_db_prep_save short-circuits it, but other
|
|
93
|
+
get_db_prep_value callers don't): here it adapts to jsonb null;
|
|
94
|
+
EncryptedJSONField maps it to SQL NULL."""
|
|
86
95
|
return adapt_json_value(value, self.encoder)
|
|
87
96
|
|
|
88
97
|
def get_db_prep_save(self, value: Any, connection: DatabaseConnection) -> Any:
|
|
@@ -90,14 +99,12 @@ class JSONField(DefaultableField):
|
|
|
90
99
|
return value
|
|
91
100
|
return self.get_db_prep_value(value, connection)
|
|
92
101
|
|
|
93
|
-
def get_transform(
|
|
94
|
-
self, lookup_name: str
|
|
95
|
-
) -> type[Transform] | Callable[..., Any] | None:
|
|
102
|
+
def get_transform(self, name: str) -> Callable[..., Transform] | None:
|
|
96
103
|
# Always returns a transform (never None in practice)
|
|
97
|
-
transform = super().get_transform(
|
|
104
|
+
transform = super().get_transform(name)
|
|
98
105
|
if transform:
|
|
99
106
|
return transform
|
|
100
|
-
return KeyTransformFactory(
|
|
107
|
+
return KeyTransformFactory(name)
|
|
101
108
|
|
|
102
109
|
def validate(self, value: Any, model_instance: Any) -> None:
|
|
103
110
|
super().validate(value, model_instance)
|
|
@@ -390,7 +390,8 @@ class ForeignKeyField(ColumnField, RelatedField):
|
|
|
390
390
|
return (self.target_field.column, self.column)
|
|
391
391
|
return (self.column, self.target_field.column)
|
|
392
392
|
|
|
393
|
-
|
|
393
|
+
@cached_property
|
|
394
|
+
def path_infos(self) -> list[PathInfo]:
|
|
394
395
|
"""Get path from this field to the related model."""
|
|
395
396
|
meta = self.remote_field.model._model_meta
|
|
396
397
|
from_meta = self.model._model_meta
|
|
@@ -402,15 +403,11 @@ class ForeignKeyField(ColumnField, RelatedField):
|
|
|
402
403
|
join_field=self,
|
|
403
404
|
m2m=False,
|
|
404
405
|
direct=True,
|
|
405
|
-
filtered_relation=filtered_relation,
|
|
406
406
|
)
|
|
407
407
|
]
|
|
408
408
|
|
|
409
409
|
@cached_property
|
|
410
|
-
def
|
|
411
|
-
return self.get_path_info()
|
|
412
|
-
|
|
413
|
-
def get_reverse_path_info(self, filtered_relation: Any = None) -> list[PathInfo]:
|
|
410
|
+
def reverse_path_infos(self) -> list[PathInfo]:
|
|
414
411
|
"""Get path from the related model to this field's model."""
|
|
415
412
|
meta = self.model._model_meta
|
|
416
413
|
from_meta = self.remote_field.model._model_meta
|
|
@@ -423,14 +420,9 @@ class ForeignKeyField(ColumnField, RelatedField):
|
|
|
423
420
|
# The reverse of a foreign key always fans out to many rows.
|
|
424
421
|
m2m=True,
|
|
425
422
|
direct=False,
|
|
426
|
-
filtered_relation=filtered_relation,
|
|
427
423
|
)
|
|
428
424
|
]
|
|
429
425
|
|
|
430
|
-
@cached_property
|
|
431
|
-
def reverse_path_infos(self) -> list[PathInfo]:
|
|
432
|
-
return self.get_reverse_path_info()
|
|
433
|
-
|
|
434
426
|
def contribute_to_class(self, cls: type[Model], name: str) -> None:
|
|
435
427
|
super().contribute_to_class(cls, name)
|
|
436
428
|
setattr(cls, name, ForwardForeignKeyDescriptor(self))
|
|
@@ -870,9 +862,7 @@ class ManyToManyField(RelatedField):
|
|
|
870
862
|
|
|
871
863
|
return name, path, args, kwargs
|
|
872
864
|
|
|
873
|
-
def _get_path_info(
|
|
874
|
-
self, direct: bool = False, filtered_relation: Any = None
|
|
875
|
-
) -> list[PathInfo]:
|
|
865
|
+
def _get_path_info(self, direct: bool = False) -> list[PathInfo]:
|
|
876
866
|
"""Called by both direct and indirect m2m traversal."""
|
|
877
867
|
int_model = self.remote_field.through
|
|
878
868
|
# M2M through model fields are always ForeignKey
|
|
@@ -886,32 +876,20 @@ class ManyToManyField(RelatedField):
|
|
|
886
876
|
)
|
|
887
877
|
if direct:
|
|
888
878
|
join1infos = linkfield1.reverse_path_infos
|
|
889
|
-
|
|
890
|
-
join2infos = linkfield2.get_path_info(filtered_relation)
|
|
891
|
-
else:
|
|
892
|
-
join2infos = linkfield2.path_infos
|
|
879
|
+
join2infos = linkfield2.path_infos
|
|
893
880
|
else:
|
|
894
881
|
join1infos = linkfield2.reverse_path_infos
|
|
895
|
-
|
|
896
|
-
join2infos = linkfield1.get_path_info(filtered_relation)
|
|
897
|
-
else:
|
|
898
|
-
join2infos = linkfield1.path_infos
|
|
882
|
+
join2infos = linkfield1.path_infos
|
|
899
883
|
|
|
900
884
|
return [*join1infos, *join2infos]
|
|
901
885
|
|
|
902
|
-
def get_path_info(self, filtered_relation: Any = None) -> list[PathInfo]:
|
|
903
|
-
return self._get_path_info(direct=True, filtered_relation=filtered_relation)
|
|
904
|
-
|
|
905
886
|
@cached_property
|
|
906
887
|
def path_infos(self) -> list[PathInfo]:
|
|
907
|
-
return self.
|
|
908
|
-
|
|
909
|
-
def get_reverse_path_info(self, filtered_relation: Any = None) -> list[PathInfo]:
|
|
910
|
-
return self._get_path_info(direct=False, filtered_relation=filtered_relation)
|
|
888
|
+
return self._get_path_info(direct=True)
|
|
911
889
|
|
|
912
890
|
@cached_property
|
|
913
891
|
def reverse_path_infos(self) -> list[PathInfo]:
|
|
914
|
-
return self.
|
|
892
|
+
return self._get_path_info(direct=False)
|
|
915
893
|
|
|
916
894
|
def _get_m2m_db_table(self) -> str:
|
|
917
895
|
"""
|