plain.postgres 0.117.0__tar.gz → 0.119.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.117.0 → plain_postgres-0.119.0}/PKG-INFO +632 -121
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/CHANGELOG.md +56 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/README.md +631 -120
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/__init__.py +17 -3
- plain_postgres-0.119.0/plain/postgres/agents/.claude/rules/plain-postgres.md +148 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/base.py +133 -12
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/cli/decorators.py +9 -2
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/cli/migrations.py +243 -249
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/cli/sync.py +28 -3
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/constants.py +0 -1
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/dialect.py +45 -24
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/expressions.py +106 -3
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/__init__.py +0 -2
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/base.py +242 -20
- plain_postgres-0.119.0/plain/postgres/fields/encrypted.py +466 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/related.py +9 -4
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/related_descriptors.py +106 -44
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/related_managers.py +54 -19
- plain_postgres-0.119.0/plain/postgres/fields/related_typed.py +167 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/temporal.py +5 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/uuid.py +5 -1
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/meta.py +8 -0
- plain_postgres-0.119.0/plain/postgres/middleware.py +45 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/migrations/autodetector.py +174 -102
- plain_postgres-0.119.0/plain/postgres/migrations/baselines.py +124 -0
- plain_postgres-0.119.0/plain/postgres/migrations/exceptions.py +118 -0
- plain_postgres-0.119.0/plain/postgres/migrations/executor.py +261 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/migrations/graph.py +1 -85
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/migrations/loader.py +132 -83
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/migrations/migration.py +12 -6
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/migrations/operations/base.py +6 -6
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/migrations/operations/special.py +8 -4
- plain_postgres-0.119.0/plain/postgres/migrations/reset.py +319 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/migrations/writer.py +26 -29
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/options.py +22 -5
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/preflight/database.py +36 -55
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/preflight/models.py +123 -1
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/query.py +1675 -195
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/query_utils.py +102 -1
- plain_postgres-0.119.0/plain/postgres/selectable.py +20 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/sql/__init__.py +2 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/sql/compiler.py +246 -96
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/sql/datastructures.py +0 -4
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/sql/query.py +31 -19
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/types.py +4 -4
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/types.pyi +67 -16
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/pyproject.toml +1 -1
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0002_test_field_removed.py +2 -1
- plain_postgres-0.119.0/tests/app/examples/migrations/0019_shadowtarget_shadowsource.py +34 -0
- plain_postgres-0.119.0/tests/app/examples/migrations/0020_stringconditionsexample.py +21 -0
- plain_postgres-0.119.0/tests/app/examples/migrations/0021_returningevent.py +19 -0
- plain_postgres-0.119.0/tests/app/examples/migrations/0022_upsertitem.py +19 -0
- plain_postgres-0.119.0/tests/app/examples/migrations/0023_upsertpair.py +21 -0
- plain_postgres-0.119.0/tests/app/examples/migrations/0024_upserttenant_upsertvaluekey_upsertscoped.py +43 -0
- plain_postgres-0.119.0/tests/app/examples/migrations/0025_upsertfloatkey.py +20 -0
- plain_postgres-0.119.0/tests/app/examples/migrations/0026_upsertdecimalkey.py +20 -0
- plain_postgres-0.119.0/tests/app/examples/migrations/0027_upsertstamped.py +34 -0
- plain_postgres-0.119.0/tests/app/examples/migrations/0028_aliascollisionexample.py +24 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/models/__init__.py +5 -0
- plain_postgres-0.119.0/tests/app/examples/models/alias_collisions.py +19 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/models/constraints.py +3 -5
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/models/defaults.py +12 -13
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/models/delete.py +36 -41
- plain_postgres-0.119.0/tests/app/examples/models/encrypted.py +17 -0
- plain_postgres-0.119.0/tests/app/examples/models/forms.py +36 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/models/indexes.py +3 -5
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/models/iteration.py +3 -5
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/models/mixins.py +7 -7
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/models/nullability.py +2 -4
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/models/querysets.py +8 -8
- plain_postgres-0.119.0/tests/app/examples/models/relationships.py +48 -0
- plain_postgres-0.119.0/tests/app/examples/models/returning.py +18 -0
- plain_postgres-0.119.0/tests/app/examples/models/shadowing.py +24 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/models/storage_parameters.py +2 -4
- plain_postgres-0.119.0/tests/app/examples/models/string_conditions.py +20 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/models/trees.py +3 -5
- plain_postgres-0.119.0/tests/app/examples/models/upsert.py +160 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/conftest.py +22 -0
- plain_postgres-0.119.0/tests/internal/conftest.py +55 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_autodetector_not_null_errors.py +2 -2
- plain_postgres-0.119.0/tests/internal/test_baselines.py +749 -0
- plain_postgres-0.119.0/tests/internal/test_conflict_lock_order.py +90 -0
- plain_postgres-0.119.0/tests/internal/test_conflict_sort_value.py +134 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_connection_lifecycle.py +62 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_db_expression_defaults.py +35 -12
- plain_postgres-0.119.0/tests/internal/test_encrypted_internals.py +85 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_fk_characterization.py +3 -3
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_literal_default_persistence.py +19 -1
- plain_postgres-0.119.0/tests/internal/test_lock_mode_sql.py +17 -0
- plain_postgres-0.119.0/tests/internal/test_m2m_value_from_object.py +39 -0
- plain_postgres-0.119.0/tests/internal/test_meta_related_objects.py +41 -0
- plain_postgres-0.119.0/tests/internal/test_migrations_reset.py +472 -0
- plain_postgres-0.119.0/tests/internal/test_random_string_sql.py +42 -0
- plain_postgres-0.119.0/tests/internal/test_replaces_removed.py +98 -0
- plain_postgres-0.119.0/tests/internal/test_returning_internals.py +47 -0
- plain_postgres-0.119.0/tests/internal/test_returning_order.py +54 -0
- plain_postgres-0.119.0/tests/internal/test_shipped_baselines.py +51 -0
- plain_postgres-0.119.0/tests/internal/test_stub_runtime_conformance.py +283 -0
- plain_postgres-0.119.0/tests/internal/test_typed_construction_preflight.py +82 -0
- plain_postgres-0.119.0/tests/internal/test_typed_where_internals.py +101 -0
- plain_postgres-0.119.0/tests/internal/test_writer_operation_options.py +28 -0
- plain_postgres-0.119.0/tests/public/test_bulk_upsert.py +726 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_databases.py +8 -2
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_delete_behaviors.py +7 -5
- plain_postgres-0.119.0/tests/public/test_encrypted_fields.py +302 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_field_defaults.py +1 -1
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_integrity_error_mapping.py +4 -2
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_m2m.py +45 -32
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_manager_assignment.py +4 -2
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_manual_pk.py +2 -2
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_order_by_expressions.py +1 -1
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_random_string_field.py +0 -32
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_related.py +6 -37
- plain_postgres-0.119.0/tests/public/test_returning.py +667 -0
- plain_postgres-0.119.0/tests/public/test_row_locking.py +312 -0
- plain_postgres-0.119.0/tests/public/test_select.py +914 -0
- plain_postgres-0.119.0/tests/public/test_typed_where.py +427 -0
- plain_postgres-0.119.0/tests/public/test_typed_where_fk.py +412 -0
- plain_postgres-0.119.0/tests/public/test_upsert.py +754 -0
- plain_postgres-0.119.0/tests/typing/README.md +65 -0
- plain_postgres-0.119.0/tests/typing/bulk_writes.py +83 -0
- plain_postgres-0.119.0/tests/typing/conditions_encrypted.py +113 -0
- plain_postgres-0.119.0/tests/typing/conditions_value_types.py +73 -0
- plain_postgres-0.119.0/tests/typing/construction_mixins.py +39 -0
- plain_postgres-0.119.0/tests/typing/construction_required_fields.py +37 -0
- plain_postgres-0.119.0/tests/typing/construction_unknown_kwargs.py +39 -0
- plain_postgres-0.119.0/tests/typing/construction_value_types.py +38 -0
- plain_postgres-0.119.0/tests/typing/field_access.py +43 -0
- plain_postgres-0.119.0/tests/typing/field_constructors.py +86 -0
- plain_postgres-0.119.0/tests/typing/queryset_access.py +46 -0
- plain_postgres-0.119.0/tests/typing/relations_foreign_key.py +76 -0
- plain_postgres-0.119.0/tests/typing/relations_reverse.py +26 -0
- plain_postgres-0.119.0/tests/typing/returning_writes.py +117 -0
- plain_postgres-0.119.0/tests/typing/select_ladder.py +116 -0
- plain_postgres-0.119.0/tests/typing/select_rows.py +250 -0
- plain_postgres-0.119.0/tests/typing/upsert_writes.py +38 -0
- plain_postgres-0.117.0/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -101
- plain_postgres-0.117.0/plain/postgres/fields/encrypted.py +0 -294
- plain_postgres-0.117.0/plain/postgres/middleware.py +0 -37
- plain_postgres-0.117.0/plain/postgres/migrations/exceptions.py +0 -51
- plain_postgres-0.117.0/plain/postgres/migrations/executor.py +0 -183
- plain_postgres-0.117.0/tests/app/examples/models/encrypted.py +0 -17
- plain_postgres-0.117.0/tests/app/examples/models/forms.py +0 -32
- plain_postgres-0.117.0/tests/app/examples/models/relationships.py +0 -43
- plain_postgres-0.117.0/tests/public/test_encrypted_fields.py +0 -176
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/.gitignore +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/CLAUDE.md +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/LICENSE +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/README.md +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/adapters.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/connection.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/constraints.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/convergence/analysis.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/convergence/corrections.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/convergence/planning.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/databases.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/binary.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/introspection/health/__init__.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/introspection/health/context.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/introspection/health/helpers.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/introspection/health/ownership.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/introspection/health/runner.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/introspection/health/types.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/introspection/schema.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/preflight/__init__.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/preflight/indexes.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/schema_lock.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/sources.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/test/database.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_apply_replan.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_connection_isolation.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_connection_pool.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_connection_self_heal.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_constraint_violation_error.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_convergence.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_convergence_constraints.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_convergence_defaults.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_convergence_fk.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_convergence_indexes.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_convergence_nullability.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_convergence_timeouts.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_databases_not_on_runtime_path.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_diagnose.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_executor_connection_hook.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_health.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_introspection.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_management_connection.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_migration_executor.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_otel_metrics.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_rollback_exc_attribution.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_schema_lock.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_schema_normalize_type.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_schema_timeouts.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/internal/test_unresolved_relation_refs.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_create_update.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_database_url.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_deferred_loading.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_exceptions.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_functions_uuid.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_iterator.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_mixins.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_modelform_roundtrip.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_only_empty_defaults.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_queryset_ordered.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_queryset_repr.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_queryset_slicing.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_raw_query.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_read_only_transactions.py +0 -0
- {plain_postgres-0.117.0 → plain_postgres-0.119.0}/tests/public/test_related_instance_filter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: plain.postgres
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.119.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
|
|
@@ -20,6 +20,7 @@ Description-Content-Type: text/markdown
|
|
|
20
20
|
- [Middleware](#middleware)
|
|
21
21
|
- [Bypassing a connection pooler for management operations](#bypassing-a-connection-pooler-for-management-operations)
|
|
22
22
|
- [Querying](#querying)
|
|
23
|
+
- [Returning affected rows](#returning-affected-rows)
|
|
23
24
|
- [Schema management](#schema-management)
|
|
24
25
|
- [Syncing](#syncing)
|
|
25
26
|
- [Structural migrations](#structural-migrations)
|
|
@@ -43,21 +44,41 @@ Description-Content-Type: text/markdown
|
|
|
43
44
|
from datetime import datetime
|
|
44
45
|
|
|
45
46
|
from plain import postgres
|
|
46
|
-
from plain.postgres import types
|
|
47
|
+
from plain.postgres import Field, types
|
|
47
48
|
from plain.passwords.models import PasswordField
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
@postgres.register_model
|
|
51
52
|
class User(postgres.Model):
|
|
52
|
-
email: str = types.EmailField()
|
|
53
|
-
password = PasswordField()
|
|
54
|
-
is_admin: bool = types.BooleanField(default=False)
|
|
55
|
-
created_at: datetime = types.DateTimeField(create_now=True)
|
|
53
|
+
email: Field[str] = types.EmailField()
|
|
54
|
+
password: Field[str] = PasswordField()
|
|
55
|
+
is_admin: Field[bool] = types.BooleanField(default=False)
|
|
56
|
+
created_at: Field[datetime] = types.DateTimeField(create_now=True)
|
|
56
57
|
|
|
57
58
|
def __str__(self) -> str:
|
|
58
59
|
return self.email
|
|
59
60
|
```
|
|
60
61
|
|
|
62
|
+
Annotate each field with `Field[T]` (the value type) — that's what gives the
|
|
63
|
+
model a type-checked constructor: `User(email="a@b.com")` flags wrong value
|
|
64
|
+
types, unknown field names, and missing required fields. A field is optional in
|
|
65
|
+
that constructor only when its definition passes `default=` (this is general,
|
|
66
|
+
not nullable-specific — a `required=False` field with no `default=` is still a
|
|
67
|
+
required constructor arg), so nullable fields use `Field[T | None]` with
|
|
68
|
+
`default=None`. DB-owned fields (`id`, `create_now`, generated values) are
|
|
69
|
+
auto-excluded from the constructor.
|
|
70
|
+
|
|
71
|
+
Field types declared outside `plain.postgres.types` — `PasswordField`, or one of
|
|
72
|
+
your own — are the exception: their stub still types the value, but they are
|
|
73
|
+
always _optional_ in the constructor, even when the column is `NOT NULL`. PEP
|
|
74
|
+
681 recognizes a field declaration by matching the constructor against a fixed
|
|
75
|
+
list, and that list is baked into `plain.postgres` where a third-party field
|
|
76
|
+
type can't join it, so the checker reads the assignment as a plain default
|
|
77
|
+
value. Wrong value types and unknown field names are still caught; only
|
|
78
|
+
requiredness is lost, and omitting one surfaces as a `NOT NULL` error on insert
|
|
79
|
+
instead. [Sharing fields across models](#sharing-fields-across-models) shows how
|
|
80
|
+
a package that ships a field type can keep it required.
|
|
81
|
+
|
|
61
82
|
Every model automatically includes an `id` field which serves as the primary
|
|
62
83
|
key. The name `id` is reserved and can't be used for other fields.
|
|
63
84
|
|
|
@@ -144,7 +165,7 @@ PLAIN_POSTGRES_MANAGEMENT_URL=postgresql://app@postgres:5432/myapp
|
|
|
144
165
|
|
|
145
166
|
When `POSTGRES_MANAGEMENT_URL` is set, these commands connect through it instead of `POSTGRES_URL`:
|
|
146
167
|
|
|
147
|
-
- `plain migrations create`, `plain migrations apply`, `plain migrations list`, `plain migrations prune
|
|
168
|
+
- `plain migrations create`, `plain migrations apply`, `plain migrations list`, `plain migrations prune`
|
|
148
169
|
- `plain postgres sync`, `plain postgres converge`, `plain postgres schema`
|
|
149
170
|
- `plain postgres diagnose`, `plain postgres drop-unknown-tables`, `plain postgres shell`
|
|
150
171
|
|
|
@@ -191,13 +212,143 @@ first_10_users = User.query.all()[:10]
|
|
|
191
212
|
|
|
192
213
|
For more advanced querying options, see the [`QuerySet`](./query.py#QuerySet) class.
|
|
193
214
|
|
|
215
|
+
### Typed conditions with where()
|
|
216
|
+
|
|
217
|
+
`where()` is a typed alternative to `filter()`. Instead of string keyword lookups, you build each condition from a field, so a type checker catches a misspelled field or a wrong value type at the call site:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from plain import postgres
|
|
221
|
+
from plain.postgres import Field, types
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@postgres.register_model
|
|
225
|
+
class User(postgres.Model):
|
|
226
|
+
email: Field[str] = types.EmailField()
|
|
227
|
+
role: Field[str] = types.TextField(max_length=20)
|
|
228
|
+
age: Field[int | None] = types.IntegerField(allow_null=True, default=None)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# Each argument is a condition; multiple arguments are ANDed together.
|
|
232
|
+
admins = User.query.where(
|
|
233
|
+
User.role.equals("admin"),
|
|
234
|
+
User.age.gte(18),
|
|
235
|
+
)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Every field exposes `equals`, `not_equal`, `gt`, `gte`, `lt`, `lte`, `is_null`, and `is_in`. Text fields add `contains`, `icontains`, `startswith`, and `endswith`. Each returns a `Q`, so you can combine them with `|` and `&` or negate with `~`:
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
# Membership, negation, and OR
|
|
242
|
+
User.query.where(User.role.is_in(["admin", "staff"]))
|
|
243
|
+
User.query.where(~User.role.equals("guest"))
|
|
244
|
+
User.query.where(User.email.endswith("@example.com") | User.role.equals("admin"))
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Conditions traverse foreign keys — accessing a field through a relation builds the joined lookup:
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
# Q(author__email="a@example.com")
|
|
251
|
+
Post.query.where(Post.author.email.equals("a@example.com"))
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
A relation is a path to traverse, not a field, so it carries no conditions of its own. To match on the relation itself, traverse to the key it points at — that's the typed spelling of `filter(author=author)`, and it compiles to the same SQL:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
Post.query.where(Post.author.id.equals(author.id))
|
|
258
|
+
Post.query.where(Post.author.id.is_in([a.id for a in authors]))
|
|
259
|
+
Post.query.where(Post.author.id.is_null()) # nullable relation
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`Post.author.equals(author)` raises `AttributeError` naming this spelling (an `AttributeError`, so `hasattr` and `getattr(..., default)` keep behaving). It isn't an oversight: to the type checker `Post.author` is `type[Author]`, which is what makes `Post.author.email.equals(...)` type-check, and a condition method there would be a runtime method the checker rejects.
|
|
263
|
+
|
|
264
|
+
Traversal starts from a **forward foreign key**. Once inside one, every relation you pass through is another hop, many-to-many included — `WidgetTag.widget.tags.name.equals("metal")` builds `Q(widget__tags__name="metal")` — and the same rule applies to the relation itself: `WidgetTag.widget.tags.equals(tag)` points you at `WidgetTag.widget.tags.id.equals(tag.id)`.
|
|
265
|
+
|
|
266
|
+
A class-level many-to-many (`Widget.tags`) is _not_ an entry point: it has no traversal wiring, and it is typed `ManyToManyManager[Tag]`, so it could not be typed as one either. Use the string path there — `Widget.query.filter(tags__name="metal")`. Reverse relations aren't traversable for the same reason (a reverse accessor is a `ClassVar`, so there is nothing for the related model to offer the checker), and the error says so.
|
|
267
|
+
|
|
268
|
+
A traversed field _is_ the related field, carrying the relation path as its name — so it offers exactly the conditions that field offers, including an encrypted field's refusals.
|
|
269
|
+
|
|
270
|
+
**A condition belongs to the model whose field built it.** `Order.query.where(User.email.equals("x"))` raises `TypeError` naming both models. A type checker can't catch this — `Field[str]` is `Field[str]` whichever model declared it — and without the check the lookup name `"email"` just resolves against `Order`, which is silently the wrong column when both models have one. A traversed condition belongs to the model the traversal _started_ from, so `Order.query.where(Order.user.email.equals("x"))` is `Order`'s, not `User`'s. A hand-written `Q(email="x")` names no model and isn't checked — it's `filter()`'s untyped spelling and behaves like it.
|
|
271
|
+
|
|
272
|
+
[Encrypted fields](#encrypted-fields) reject value comparisons because their ciphertext is non-deterministic — only `is_null()` is available, and any other condition method (`equals`, `is_in`, …) raises `TypeError`.
|
|
273
|
+
|
|
274
|
+
### Selecting columns with select()
|
|
275
|
+
|
|
276
|
+
`select()` pulls back specific columns as typed rows instead of model instances. You pass typed field references, and a type checker knows the exact shape of each row:
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
from plain.postgres import Field, types
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@postgres.register_model
|
|
283
|
+
class User(postgres.Model):
|
|
284
|
+
email: Field[str] = types.EmailField()
|
|
285
|
+
age: Field[int | None] = types.IntegerField(allow_null=True, default=None)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# list-like of tuple[str, int | None], precisely typed
|
|
289
|
+
rows = User.query.where(User.age.gte(18)).select(User.email, User.age)
|
|
290
|
+
for email, age in rows:
|
|
291
|
+
...
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
There are three modes:
|
|
295
|
+
|
|
296
|
+
- **Tuples** (default) — one tuple per row, typed per column: `select(User.email, User.age)` yields `tuple[str, int | None]`.
|
|
297
|
+
- **Flat scalars** — a single column unwrapped, with `flat=True`: `select(User.email, flat=True)` yields `str`. `flat=True` accepts exactly one column.
|
|
298
|
+
- **Dataclasses** — map each column onto a dataclass with `result_type=`: `select(User.email, User.age, result_type=UserStats)` yields `UserStats`. Columns map to dataclass fields **positionally**, so the selection order must match the dataclass field order, and each selected field's name must match the dataclass field at the same position.
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
from dataclasses import dataclass
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@dataclass
|
|
305
|
+
class UserStats:
|
|
306
|
+
email: str
|
|
307
|
+
age: int | None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
stats = User.query.select(User.email, User.age, result_type=UserStats)
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
The return value is a [`RowQuerySet`](./query.py#RowQuerySet) — that is the name to reach for when you need to annotate one:
|
|
314
|
+
|
|
315
|
+
```python
|
|
316
|
+
from plain.postgres import RowQuerySet
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def adults() -> RowQuerySet[tuple[str, int | None]]:
|
|
320
|
+
return User.query.where(User.age.gte(18)).select(User.email, User.age)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
You can select expression columns too — `select(User.id, Sum("amount"))`, or an `F()` — but an expression column types as `Any` (its output type isn't tracked yet). The fields around it stay precise, so `select(User.id, Sum("amount"))` types as `tuple[int, Any]`.
|
|
324
|
+
|
|
325
|
+
Per-column typing runs to **ten columns**. An eleventh is still selected and still returns rows, but the row type degrades to `tuple[Any, ...]` — reach for `result_type=` when a row is that wide.
|
|
326
|
+
|
|
327
|
+
`select()` goes last in a chain: `annotate()` must come before it, because an annotation appends a column and would change the row shape out from under the type `select()` declared. `annotate()` after `select()` raises `TypeError` saying so. `prefetch_related()` is refused in both orders — a prefetch attaches related objects to a model instance's attributes, and a row has nowhere to put them; select the columns you need from the related model instead.
|
|
328
|
+
|
|
329
|
+
Re-selecting replaces the **column list**, not the joins: an expression that reached through a relation (`select(Upper("tags__name"))`) leaves its join in place, so a later `select(Widget.name)` still returns one row per joined row — and `count()`/`exists()` count those. This is `annotate(...)` followed by `values_list(...)` behaving as it always has; trimming joins no queryset needs any more is out of scope here.
|
|
330
|
+
|
|
331
|
+
`distinct()` with an `order_by()` on a column you didn't select returns duplicates: the ordering column has to go into the `SELECT` list for Postgres to sort by it, so `SELECT DISTINCT` deduplicates on that column too. Order by something you selected, or drop the ordering. This is `values_list()`'s behavior as well, not new to `select()`.
|
|
332
|
+
|
|
333
|
+
Columns annotated `Field[Any]` are rejected by `select()`, because `Any` satisfies the model-valued `__get__` overload and class access resolves as `type[Any]` rather than a field. That's the same reason the field-annotation guidance says never to annotate a field `Field[Any]` — use the concrete type, or `Field[object]` when the column really does hold arbitrary JSON, which `select()` types as `object`.
|
|
334
|
+
|
|
335
|
+
**`select()` returns rows, not partial model instances.** This is deliberate: a model instance with only some columns loaded is a type-level lie — the type checker thinks every field is present, so touching an unselected column looks fine but fails or fires a hidden query at runtime. Honest tuples/dataclasses keep the types truthful. As a result, iteration, `first()`, `get()`, `iterator()`, and slicing all return rows, and anything that would read or write model rows, or change the selected columns — `update()`, `delete()`, `get_or_create()`, `values()`, `values_list()`, `annotate()`, `prefetch_related()` — raises `TypeError`. `update()` and `delete()` refuse a queryset in row mode however it got there, `values()` and `values_list()` included.
|
|
336
|
+
|
|
337
|
+
`select()` takes typed references only — a bare string like `select("email")` raises `TypeError` (use `User.email`).
|
|
338
|
+
|
|
339
|
+
**A column belongs to the model whose field built it**, the same as [a condition does](#querying-with-typed-conditions): `Order.query.select(User.email)` raises `TypeError` naming both models. A type checker can't catch it — `Field[str]` is `Field[str]` whichever model declared it — and without the check the name `"email"` just resolves against `Order`, silently the wrong column when both models have one. Expressions are unaffected: `F("email")` and `Upper("email")` take a string resolved against whatever query they land in, like `filter()`'s kwargs.
|
|
340
|
+
|
|
341
|
+
**Relations are not selectable yet.** `select(Post.author)` (the relation) and `select(Post.author.city)` (a column through it) both raise `TypeError`, and so does `select(Post.author.id)` — the foreign key column itself. The reason is nullability: a column reached through a relation arrives over a join, so a nullable relation yields `None` where the traversed field's type says it can't. Until `select()` can express that, `values_list("author__id", flat=True)` is the spelling, and the error message names it.
|
|
342
|
+
|
|
343
|
+
**`select()` hands back a plain `RowQuerySet`, not your custom QuerySet subclass.** Chain your own methods before `select()`, not after — `User.query.active().select(...)` works, `User.query.select(...).active()` raises `AttributeError`.
|
|
344
|
+
|
|
194
345
|
### Custom QuerySets
|
|
195
346
|
|
|
196
|
-
You can customize [`QuerySet`](./query.py#QuerySet) classes to provide specialized query methods. Define a custom QuerySet and assign it to your model's `query` attribute:
|
|
347
|
+
You can customize [`QuerySet`](./query.py#QuerySet) classes to provide specialized query methods. Define a custom QuerySet and assign it to your model's `query` attribute as a `ClassVar` (so it isn't treated as a constructor field):
|
|
197
348
|
|
|
198
349
|
```python
|
|
199
|
-
from typing import Self
|
|
200
|
-
from plain.postgres import types
|
|
350
|
+
from typing import ClassVar, Self
|
|
351
|
+
from plain.postgres import Field, types
|
|
201
352
|
|
|
202
353
|
|
|
203
354
|
class PublishedQuerySet(postgres.QuerySet["Article"]):
|
|
@@ -210,10 +361,10 @@ class PublishedQuerySet(postgres.QuerySet["Article"]):
|
|
|
210
361
|
|
|
211
362
|
@postgres.register_model
|
|
212
363
|
class Article(postgres.Model):
|
|
213
|
-
title: str = types.TextField(max_length=200)
|
|
214
|
-
status: str = types.TextField(max_length=20)
|
|
364
|
+
title: Field[str] = types.TextField(max_length=200)
|
|
365
|
+
status: Field[str] = types.TextField(max_length=20)
|
|
215
366
|
|
|
216
|
-
query = PublishedQuerySet()
|
|
367
|
+
query: ClassVar[PublishedQuerySet] = PublishedQuerySet()
|
|
217
368
|
|
|
218
369
|
|
|
219
370
|
# Usage - all methods available on Article.query
|
|
@@ -233,24 +384,11 @@ special_qs = SpecialQuerySet.from_model(Article)
|
|
|
233
384
|
|
|
234
385
|
### Typing QuerySets
|
|
235
386
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
from plain import postgres
|
|
242
|
-
from plain.postgres import types
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
@postgres.register_model
|
|
246
|
-
class User(postgres.Model):
|
|
247
|
-
email: str = types.EmailField()
|
|
248
|
-
is_admin: bool = types.BooleanField(default=False)
|
|
249
|
-
|
|
250
|
-
query: postgres.QuerySet[User] = postgres.QuerySet()
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
With this annotation, type checkers will know that `User.query.get()` returns a `User` instance and `User.query.filter()` returns `QuerySet[User]`. This is optional but improves IDE autocomplete and type checking.
|
|
387
|
+
`Model.query` is typed automatically — `User.query.get()` returns a `User` and
|
|
388
|
+
`User.query.filter()` returns `QuerySet[User]` with no extra annotation. Don't
|
|
389
|
+
redeclare `query` just to type it; the base provides `QuerySet[Self]`. Declare
|
|
390
|
+
`query` only when attaching a **custom** QuerySet, and then as a `ClassVar`
|
|
391
|
+
(see [Custom QuerySets](#custom-querysets) above).
|
|
254
392
|
|
|
255
393
|
### Raw SQL
|
|
256
394
|
|
|
@@ -410,6 +548,176 @@ for name in names:
|
|
|
410
548
|
Tag.query.bulk_create([Tag(name=name) for name in names])
|
|
411
549
|
```
|
|
412
550
|
|
|
551
|
+
`bulk_create` is insert-only. To insert new rows and update the ones that
|
|
552
|
+
already exist in a single statement, use `bulk_upsert` (below).
|
|
553
|
+
|
|
554
|
+
#### Use `bulk_upsert` to insert-or-update in one statement
|
|
555
|
+
|
|
556
|
+
`bulk_upsert(objs, *, update_fields, unique_fields, batch_size=None)` issues one
|
|
557
|
+
`INSERT ... ON CONFLICT (unique_fields) DO UPDATE SET ... RETURNING` per batch.
|
|
558
|
+
Rows that don't exist yet are inserted; rows that collide on `unique_fields` have
|
|
559
|
+
their `update_fields` overwritten. You get back the objects you passed in, in the
|
|
560
|
+
order you passed them (a new list — `objs` itself is never reordered), each with
|
|
561
|
+
its DB-generated fields (primary key, DB defaults) populated.
|
|
562
|
+
|
|
563
|
+
```python
|
|
564
|
+
# Insert new items, refresh `value`/`expires_at` on any existing key.
|
|
565
|
+
CachedItem.query.bulk_upsert(
|
|
566
|
+
[CachedItem(key=k, value=v, expires_at=exp) for k, v in items],
|
|
567
|
+
update_fields=[CachedItem.value, CachedItem.expires_at],
|
|
568
|
+
unique_fields=[CachedItem.key],
|
|
569
|
+
)
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
- `update_fields` and `unique_fields` take field references (`Model.field`), not
|
|
573
|
+
strings. A foreign key is named by the relation itself — `Model.tenant`, which
|
|
574
|
+
resolves to the `tenant_id` column. (This is the one write API that takes
|
|
575
|
+
`Model.fk`. `returning()` refuses it, because there it would be ambiguous with
|
|
576
|
+
asking for the whole related object; here a column list can only mean the
|
|
577
|
+
column.)
|
|
578
|
+
- `unique_fields` must name the **primary key** or a `UniqueConstraint` declared
|
|
579
|
+
on the model (no condition, no expressions) — this is the conflict target. A
|
|
580
|
+
unique `Index` is not enough; declare a `UniqueConstraint`.
|
|
581
|
+
- `update_fields` must be concrete, non-primary-key, must not name the same
|
|
582
|
+
column twice (Postgres assigns each column once per statement), and must not
|
|
583
|
+
overlap `unique_fields`. A column the database fills in (`create_now`,
|
|
584
|
+
`generate=True`, `RandomStringField`) can't be named either — the update would
|
|
585
|
+
overwrite the stored value with a freshly evaluated default.
|
|
586
|
+
- Every object must have a non-null value for every unique field. `NULL` never
|
|
587
|
+
conflicts in Postgres, so it can't be upserted. A database-generated column
|
|
588
|
+
(`create_now`, `generate=True`, `RandomStringField`) can't be a unique field
|
|
589
|
+
either — your objects never hold its value, so it could never conflict. Nor
|
|
590
|
+
can an `update_now=True` column, which is stamped again on every write.
|
|
591
|
+
- **Two objects with the same unique key in one batch raise `ValueError`.**
|
|
592
|
+
Postgres won't touch a row twice in one statement. Split across batches it's
|
|
593
|
+
allowed — the first inserts, the second updates, and the later write wins.
|
|
594
|
+
- **`update_now=True` columns are refreshed on a conflict automatically.** You
|
|
595
|
+
don't name them in `update_fields`; a row that gets updated gets a fresh
|
|
596
|
+
stamp, and the object handed back carries the same one.
|
|
597
|
+
- **An `id` you set is kept on the insert path; on a conflict the stored row
|
|
598
|
+
wins.** A new row is written with the `id` you gave it. A conflicting one
|
|
599
|
+
already has an `id`, and that is the one hydrated back onto your object — the
|
|
600
|
+
row in the table is the truth. An `id` that collides with a _different_ row
|
|
601
|
+
raises `psycopg.errors.UniqueViolation`, like any set-based write.
|
|
602
|
+
- Every object is sorted by its conflict key before anything is sent, so
|
|
603
|
+
concurrent `bulk_upsert` calls over overlapping keys lock rows in the same
|
|
604
|
+
order and can't deadlock each other. Returned rows are mapped onto the objects
|
|
605
|
+
by position, exactly as `bulk_create` does.
|
|
606
|
+
- Like `bulk_create`, the write is against the table: a filter on the queryset
|
|
607
|
+
you call it from doesn't narrow or exclude anything.
|
|
608
|
+
|
|
609
|
+
For a single row, reach for `upsert` (below) instead — it returns the object and
|
|
610
|
+
a `created` flag rather than a list.
|
|
611
|
+
|
|
612
|
+
#### Use `upsert` for a single insert-or-update
|
|
613
|
+
|
|
614
|
+
`upsert(*, unique_fields, defaults=None, create_defaults=None, conflict_defaults=None, **kwargs)`
|
|
615
|
+
is the single-row counterpart to `bulk_upsert`. It runs one
|
|
616
|
+
`INSERT ... ON CONFLICT (unique_fields) DO UPDATE SET ... RETURNING` statement and
|
|
617
|
+
returns `(obj, created)` — `created` is `True` when a new row was inserted,
|
|
618
|
+
`False` when the conflicting row was updated. The object is hydrated from the
|
|
619
|
+
post-write row, so there's no second query.
|
|
620
|
+
|
|
621
|
+
```python
|
|
622
|
+
# Insert the flag, or refresh used_at on the existing one.
|
|
623
|
+
flag, created = Flag.query.upsert(
|
|
624
|
+
name="beta-dashboard",
|
|
625
|
+
defaults={"used_at": timezone.now()},
|
|
626
|
+
unique_fields=[Flag.name],
|
|
627
|
+
)
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
`unique_fields` takes field references (`Model.field`), like `bulk_upsert`. The
|
|
631
|
+
value sources below stay string-keyed — they follow the `kwargs` idiom.
|
|
632
|
+
|
|
633
|
+
Value sources, lowest precedence first — where the same key appears in two of
|
|
634
|
+
them, `create_defaults` loses to `defaults`, which loses to `**kwargs`:
|
|
635
|
+
|
|
636
|
+
- `create_defaults` is applied on **insert only** — extras that must not change
|
|
637
|
+
when the row already exists.
|
|
638
|
+
- `defaults` and `**kwargs` are applied on **both** insert and conflict-update.
|
|
639
|
+
`kwargs` carries the identifying values (including the unique fields).
|
|
640
|
+
- `conflict_defaults` applies to the `DO UPDATE SET` **only** — it never changes
|
|
641
|
+
the inserted row. A value can be a plain value or an expression, so
|
|
642
|
+
`{"count": F("count") + 1}` is an atomic counter that reads the existing row:
|
|
643
|
+
|
|
644
|
+
```python
|
|
645
|
+
view, created = PageView.query.upsert(
|
|
646
|
+
path="/home",
|
|
647
|
+
conflict_defaults={"count": F("count") + 1},
|
|
648
|
+
unique_fields=[PageView.path],
|
|
649
|
+
)
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
Two expressions reach two different rows inside a conflict update. `F("count")`
|
|
653
|
+
reads the row already **stored**; `Excluded("count")` reads the row the INSERT
|
|
654
|
+
**proposed**, compiling to `EXCLUDED."count"`. Combine them to accumulate the
|
|
655
|
+
incoming value instead of overwriting it — the whole statement is one atomic
|
|
656
|
+
`UPDATE`, so concurrent callers each add their own delta:
|
|
657
|
+
|
|
658
|
+
```python
|
|
659
|
+
from plain.postgres import Excluded, F
|
|
660
|
+
|
|
661
|
+
# Add this batch's 7 views to whatever is already stored.
|
|
662
|
+
view, created = PageView.query.upsert(
|
|
663
|
+
path="/home",
|
|
664
|
+
count=7,
|
|
665
|
+
conflict_defaults={"count": F("count") + Excluded("count")},
|
|
666
|
+
unique_fields=[PageView.path],
|
|
667
|
+
)
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
`Excluded()` is only meaningful while the conflict update's assignments are
|
|
671
|
+
being built. Anywhere else raises a `FieldError` — including as an inserted
|
|
672
|
+
value in `kwargs`/`defaults`/`create_defaults` of the very same call, where it
|
|
673
|
+
would be naming the row being written, and in `filter()`, `update()` or
|
|
674
|
+
`annotate()`.
|
|
675
|
+
|
|
676
|
+
On conflict the `SET` clause covers:
|
|
677
|
+
|
|
678
|
+
- every non-unique, non-PK column from `kwargs`/`defaults`, each taking the value
|
|
679
|
+
the INSERT proposed;
|
|
680
|
+
- every `DateTimeField(update_now=True)` column, whose fresh `pre_save()`
|
|
681
|
+
timestamp rides along in the INSERT and would otherwise go stale (a
|
|
682
|
+
`create_now`-only column is _not_ in the `SET`, so it keeps its original
|
|
683
|
+
value);
|
|
684
|
+
- every column `conflict_defaults` names — replacing the proposed value when the
|
|
685
|
+
column is already in the `SET`, adding it when it isn't.
|
|
686
|
+
|
|
687
|
+
Columns nobody wrote are left alone, and `create_defaults` never take part in the
|
|
688
|
+
update. Naming a **database-owned** column (`create_now`, `generate=True`,
|
|
689
|
+
`RandomStringField`) in `kwargs`/`defaults` is an error rather than a silent
|
|
690
|
+
reset: `EXCLUDED` carries a freshly evaluated default, so the conflict update
|
|
691
|
+
would overwrite the stored creation timestamp every time. A column that's also
|
|
692
|
+
`update_now` is exempt — refreshing it is the point. Unlike `bulk_upsert`, `upsert` derives its `SET` columns rather than
|
|
693
|
+
taking them, so a `conflict_defaults` key may not name a unique field — that's
|
|
694
|
+
the conflict target.
|
|
695
|
+
|
|
696
|
+
Every value source resolves callables, and every key must name a **column** — a
|
|
697
|
+
settable property is refused, since the `SET` clause is derived from columns and
|
|
698
|
+
a property could only ever be written on the insert half.
|
|
699
|
+
|
|
700
|
+
`unique_fields` must name a `UniqueConstraint` declared on the model (no
|
|
701
|
+
condition, no expressions) and every unique field must be non-null. It can't be
|
|
702
|
+
the primary key: Postgres generates the identity value, so a caller has nothing
|
|
703
|
+
to conflict on.
|
|
704
|
+
|
|
705
|
+
Three things to keep in mind:
|
|
706
|
+
|
|
707
|
+
- **`kwargs` are values, not filters.** A keyword that isn't part of the conflict
|
|
708
|
+
key doesn't narrow which row is matched — `unique_fields` alone decides that —
|
|
709
|
+
it's just another column written to whichever row conflicts.
|
|
710
|
+
- **The queryset's filters don't scope it either.** `qs.filter(...).upsert(...)`
|
|
711
|
+
writes the conflicting row whether or not it matches the filter — the conflict
|
|
712
|
+
constraint decides which row is touched. It isn't refused because the
|
|
713
|
+
related-manager wrappers call through a filtered queryset. To scope an upsert,
|
|
714
|
+
fold the scoping column into `unique_fields` (and into the constraint), or do a
|
|
715
|
+
locked read and write instead.
|
|
716
|
+
- **The merged row isn't validated**, and a constraint violation surfaces as a raw
|
|
717
|
+
`psycopg.IntegrityError`, not a `ValidationError`. `upsert` looks single-row
|
|
718
|
+
like `create()`, but it's a set-based write like `bulk_upsert` (see
|
|
719
|
+
[Validation](#validation)).
|
|
720
|
+
|
|
413
721
|
#### Use queryset `.update()` / `.delete()` for mass operations
|
|
414
722
|
|
|
415
723
|
```python
|
|
@@ -450,6 +758,44 @@ for row in HugeTable.query.iterator(chunk_size=2000):
|
|
|
450
758
|
process(row)
|
|
451
759
|
```
|
|
452
760
|
|
|
761
|
+
## Returning affected rows
|
|
762
|
+
|
|
763
|
+
`QuerySet.update()` and `QuerySet.delete()` return an `int` rowcount. Chain `returning()` before the write to get the affected rows back instead — Postgres' `RETURNING` clause fetches them in the same statement, so there's no second query.
|
|
764
|
+
|
|
765
|
+
```python
|
|
766
|
+
# No arguments: rows come back as model instances.
|
|
767
|
+
running = Job.query.filter(status="pending").returning().update(status="running")
|
|
768
|
+
for job in running:
|
|
769
|
+
print(job.id, job.status) # reflects the post-update values
|
|
770
|
+
|
|
771
|
+
# Field references: rows come back as dicts of just those columns.
|
|
772
|
+
deleted = (
|
|
773
|
+
Event.query.filter(created_at__lt=cutoff)
|
|
774
|
+
.returning(Event.id, Event.payload)
|
|
775
|
+
.delete()
|
|
776
|
+
)
|
|
777
|
+
for row in deleted:
|
|
778
|
+
print(row["id"], row["payload"]) # the rows as they were deleted
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
- **`returning()`** returns full model instances. For `update()` they hold the new values and stay live. For `delete()` they are **read-only snapshots**: every value is there to read, the id included, but the row is gone, so `create()`, `update()` and `delete()` on them raise.
|
|
782
|
+
- That is the opposite of `Model.delete()`, which clears the instance's id and leaves it re-creatable — that instance is a row you still hold and may want to put back, while a snapshot is a record of one that was removed, and its id is the point.
|
|
783
|
+
- **`returning(Model.field, ...)`** returns a list of dicts with only those columns. Pass field references (`Model.field`), not strings; a many-to-many field or one from another model raises an error at the `returning()` call.
|
|
784
|
+
- **A foreign key can't be named here.** At class level `Model.fk` is the relation — that is what lets `where()` traverse it, as in `Child.parent.name.equals(...)` — not its column, so `returning(Child.parent)` raises `FieldError`. Foreign key columns come back through no-argument `returning()`, which hands you whole instances.
|
|
785
|
+
- Without `returning()`, `update()`/`delete()` return an `int` as before.
|
|
786
|
+
- `returning()` only applies to `update()` and `delete()`. Any other write on the same queryset — `create()`, `bulk_create()`, `bulk_upsert()`, `bulk_update()`, `get_or_create()`, `upsert()` — raises `TypeError` rather than quietly dropping it.
|
|
787
|
+
- `returning()` keeps the queryset's own class, so a custom `QuerySet` and its methods survive it. Chain your own methods before `returning()` — a type checker sees the returning shape after it, not your subclass.
|
|
788
|
+
|
|
789
|
+
A row lock belongs on the read side of the write, and it composes in either order. The write then needs an open `transaction.atomic()`, and is emitted as a locking sub-select so the lock has somewhere to live — see [Locking a set-based write](#locking-a-set-based-write).
|
|
790
|
+
|
|
791
|
+
`returning()` is inert for reads. It describes what the next `update()` or `delete()` hands back, so iterating, `count()`, `first()` and `values()` on the same queryset behave exactly as they would without it — which is what lets you inspect a chain before writing it.
|
|
792
|
+
|
|
793
|
+
The values you get back are whatever the statement wrote, exactly as Postgres holds them. A set-based `update()` doesn't run Python-side field hooks, so an `update_now=True` timestamp comes back unchanged unless the `update()` set it.
|
|
794
|
+
|
|
795
|
+
`RETURNING` only reports rows of the statement's own target table. Rows removed by a cascading `ON DELETE` are never included — a `delete()` with `returning()` gives you the parent rows you deleted, not the children Postgres cascaded.
|
|
796
|
+
|
|
797
|
+
Every affected row is fetched and built into memory at once, so `returning()` belongs on writes you've already bounded by a filter. For a write that spans a whole table, take the rowcount and page through the rows separately.
|
|
798
|
+
|
|
453
799
|
## Transactions
|
|
454
800
|
|
|
455
801
|
By default, each query runs in its own implicit transaction and is committed immediately (autocommit mode). When you need multiple queries to succeed or fail together — like creating a user and their profile — wrap them in an explicit transaction.
|
|
@@ -509,6 +855,81 @@ with read_only():
|
|
|
509
855
|
User.query.count() # still works — outer txn is healthy
|
|
510
856
|
```
|
|
511
857
|
|
|
858
|
+
### Row-level locking
|
|
859
|
+
|
|
860
|
+
Lock the rows a query selects so concurrent transactions can't change them until yours commits. Postgres offers four lock strengths, from strongest to weakest, each with its own QuerySet method:
|
|
861
|
+
|
|
862
|
+
```python
|
|
863
|
+
with transaction.atomic():
|
|
864
|
+
account = Account.query.for_update().get(id=1) # FOR UPDATE
|
|
865
|
+
account.balance -= 100
|
|
866
|
+
account.update(fields=["balance"])
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
| Method | SQL clause | Use it when |
|
|
870
|
+
| --------------------- | ------------------- | -------------------------------------------------------------------------- |
|
|
871
|
+
| `for_update()` | `FOR UPDATE` | You intend to update or delete the row. |
|
|
872
|
+
| `for_no_key_update()` | `FOR NO KEY UPDATE` | Same, but you won't touch the primary key — lets key-share locks proceed. |
|
|
873
|
+
| `for_share()` | `FOR SHARE` | You need the row to stay put while you read it, but others may also share. |
|
|
874
|
+
| `for_key_share()` | `FOR KEY SHARE` | Weakest — only blocks changes to the row's key. |
|
|
875
|
+
|
|
876
|
+
Locking requires an open transaction. The method itself just builds the queryset — evaluating a locked queryset outside `transaction.atomic()` is what raises `TransactionManagementError`.
|
|
877
|
+
|
|
878
|
+
All four accept the same options:
|
|
879
|
+
|
|
880
|
+
- `nowait=True` — raise instead of waiting if a row is already locked.
|
|
881
|
+
- `skip_locked=True` — skip already-locked rows instead of waiting (can't be combined with `nowait`).
|
|
882
|
+
- `of=("self", "related")` — lock only the named tables in a join rather than every selected row.
|
|
883
|
+
|
|
884
|
+
```python
|
|
885
|
+
# Claim the next available job without blocking on rows another worker holds
|
|
886
|
+
job = Job.query.for_update(skip_locked=True).filter(status="pending").first()
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
Chaining more than one lock method keeps only the last one, options included.
|
|
890
|
+
|
|
891
|
+
Postgres can only lock rows that map one-to-one onto table rows, so a lock can't be combined with `distinct()`, an aggregate annotation, or a window annotation. Either order raises `psycopg.NotSupportedError` when the queryset is built, naming the lock method:
|
|
892
|
+
|
|
893
|
+
```python
|
|
894
|
+
Widget.query.distinct().for_update() # NotSupportedError
|
|
895
|
+
Widget.query.for_update().distinct() # same error, either way round
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
`count()` and `aggregate()` are the exception — they compile to an aggregate query of their own, so they drop the lock rather than reject it.
|
|
899
|
+
|
|
900
|
+
#### Locking a set-based write
|
|
901
|
+
|
|
902
|
+
A lock also applies to `update()` and `delete()` on the same queryset. Neither statement takes a locking clause of its own, so the write is emitted as a locking sub-select:
|
|
903
|
+
|
|
904
|
+
```python
|
|
905
|
+
# Claim *all* pending rows matching the filter, in one statement
|
|
906
|
+
with transaction.atomic():
|
|
907
|
+
claimed = (
|
|
908
|
+
Job.query.filter(status="pending")
|
|
909
|
+
.for_update(skip_locked=True)
|
|
910
|
+
.returning()
|
|
911
|
+
.update(status="running")
|
|
912
|
+
)
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
```sql
|
|
916
|
+
UPDATE "jobs" SET "status" = 'running'
|
|
917
|
+
WHERE "id" IN (
|
|
918
|
+
SELECT U0."id" FROM "jobs" U0 WHERE U0."status" = 'pending' FOR UPDATE OF U0 SKIP LOCKED
|
|
919
|
+
)
|
|
920
|
+
RETURNING ...
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
It is still one statement. A second worker running the same write skips the rows the first one holds instead of blocking on them, so each row is claimed once.
|
|
924
|
+
|
|
925
|
+
Note what this is and isn't: it takes **every** row the filter matches, so it suits draining a batch, not handing one unit of work to one worker. A bounded claim would need a sliced write (`[:1]`), and `update()`/`delete()` reject a sliced queryset — so for a per-worker claim, take one row with the locked read above (`for_update(skip_locked=True)` + `first()`) and write it separately.
|
|
926
|
+
|
|
927
|
+
The `transaction.atomic()` is required, same as for a locked read: a locked write outside a transaction raises `TransactionManagementError`. Nothing else honors the lock — without a transaction there is nothing for it to be held until.
|
|
928
|
+
|
|
929
|
+
**A locked write locks only the target table's rows.** When the filter spans a relation, the sub-select joins the other tables to look values up, and a bare `FOR UPDATE` would lock a row in each of them — so a write whose filter reads a parent would wait on (or, with `skip_locked=True`, silently skip) rows it never touches. The clause is emitted as `FOR UPDATE OF <target>` so that can't happen. To lock related rows as well, take them with a separate locked read.
|
|
930
|
+
|
|
931
|
+
That is also why `of=` can only name `"self"` on a write: the sub-select reads one column — this table's id — so a related name has nothing to point at, and `update()`/`delete()` raise `TypeError` rather than let it fail deeper down. Dropping `of=` is the same thing; the write locks its own rows either way.
|
|
932
|
+
|
|
512
933
|
## Schema management
|
|
513
934
|
|
|
514
935
|
Schema changes fall into three categories, each with a different author and apply model:
|
|
@@ -603,15 +1024,16 @@ Key flags:
|
|
|
603
1024
|
|
|
604
1025
|
Shared commands (apply equally to structural and data migrations):
|
|
605
1026
|
|
|
606
|
-
| Command
|
|
607
|
-
|
|
|
608
|
-
| `plain migrations apply`
|
|
609
|
-
| `plain migrations apply --plan`
|
|
610
|
-
| `plain migrations apply --check`
|
|
611
|
-
| `plain migrations apply --fake`
|
|
612
|
-
| `plain migrations list`
|
|
613
|
-
| `plain migrations
|
|
614
|
-
| `plain migrations prune
|
|
1027
|
+
| Command | Purpose |
|
|
1028
|
+
| ---------------------------------- | ----------------------------------------------------------------- |
|
|
1029
|
+
| `plain migrations apply` | Apply pending migrations |
|
|
1030
|
+
| `plain migrations apply --plan` | Preview what would run |
|
|
1031
|
+
| `plain migrations apply --check` | Exit non-zero if unapplied migrations exist (for CI) |
|
|
1032
|
+
| `plain migrations apply --fake` | Mark as applied without running SQL |
|
|
1033
|
+
| `plain migrations list` | View migration status by package |
|
|
1034
|
+
| `plain migrations prune` | Remove orphan migration records |
|
|
1035
|
+
| `plain migrations prune <package>` | Remove every record for one package (lets its baseline run again) |
|
|
1036
|
+
| `plain migrations reset <package>` | Replace the package's history with one baseline |
|
|
615
1037
|
|
|
616
1038
|
#### Development workflow
|
|
617
1039
|
|
|
@@ -626,43 +1048,65 @@ Use this when migrations exist only in your local dev environment and haven't be
|
|
|
626
1048
|
3. `plain migrations create` — creates a single fresh migration with all the changes
|
|
627
1049
|
4. `plain migrations apply --fake` — marks the new migration as applied (the schema is already correct from the old migrations)
|
|
628
1050
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
Use this when migrations have already been committed or deployed to other environments.
|
|
1051
|
+
Migrations that are committed but not yet deployed anywhere can be consolidated the same way if every developer resets their database; production then applies the consolidated migration normally. Once a migration has reached any deployed environment, use a full reset (below).
|
|
632
1052
|
|
|
633
|
-
|
|
1053
|
+
#### Baselines
|
|
634
1054
|
|
|
635
|
-
**
|
|
1055
|
+
A package whose migration history has been reset ships one **baseline** migration in place of the deleted files. It is an ordinary migration with three extra attributes:
|
|
636
1056
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
1057
|
+
```python
|
|
1058
|
+
class Migration(migrations.Migration):
|
|
1059
|
+
supersedes = "0008_add_widgets" # the sentinel: the last deleted migration; its record proves a database is caught up
|
|
1060
|
+
retired = (
|
|
1061
|
+
"0001_initial",
|
|
1062
|
+
...,
|
|
1063
|
+
"0008_add_widgets",
|
|
1064
|
+
) # every deleted name, so dependencies on them still resolve
|
|
1065
|
+
shipped_in = "0.61" # the release that shipped the reset; empty until it has
|
|
1066
|
+
dependencies = (("users", "0001_initial"),)
|
|
1067
|
+
operations = (migrations.CreateModel(...), ...)
|
|
1068
|
+
```
|
|
1069
|
+
|
|
1070
|
+
What `plain postgres sync` (or `plain migrations apply`) does with it depends only on what the database has recorded:
|
|
1071
|
+
|
|
1072
|
+
| Database has | Result |
|
|
1073
|
+
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
1074
|
+
| No records for the package, no tables (fresh, or newly installed) | Runs the baseline like any migration |
|
|
1075
|
+
| No records for the package, but its tables exist | Refuses: record it with `apply <pkg> <baseline> --fake` if the schema is current, else drop the tables |
|
|
1076
|
+
| The baseline recorded | Nothing |
|
|
1077
|
+
| The `supersedes` migration recorded | Records the baseline without running it - one row, once |
|
|
1078
|
+
| Records, but not the `supersedes` migration | Refuses: upgrade through the last release that still has that migration |
|
|
1079
|
+
| Records, but none of the package's tables | Refuses: `plain migrations prune <pkg>` drops the records; then it runs |
|
|
1080
|
+
|
|
1081
|
+
`plain migrations apply --plan --check` and `plain postgres sync --check` report a baseline waiting to be recorded as a pending change, and `plain preflight` reports a refusal as an error. Nothing ever deletes records on its own; `prune` stays explicit. A database where the baseline _ran_ (rather than was recorded) cannot roll back to code from before the reset.
|
|
642
1082
|
|
|
643
1083
|
#### Resetting migrations
|
|
644
1084
|
|
|
645
|
-
|
|
1085
|
+
Once **every environment** has applied a package's migrations, its history can be collapsed into one baseline:
|
|
1086
|
+
|
|
1087
|
+
```bash
|
|
1088
|
+
plain migrations reset <package>
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
The command writes `NNNN_baseline.py` past the current leaf (the package's newest migration) — the package's schema as `CreateModel`s, `supersedes` set to the leaf, `retired` set to every deleted name — and deletes the old files. Commit the new file and the deletions together. Existing databases adopt it with one record on their next `plain postgres sync`; the retired records stay as the rollback path; a fresh database runs it. Other packages' migrations that depended on a deleted name resolve to the baseline; nothing there needs rewriting. Do not add a `prune` step. Before it touches anything it checks, in this order:
|
|
646
1092
|
|
|
647
|
-
|
|
1093
|
+
- The models agree with the history (`plain migrations create` would write nothing). A change the history doesn't hold would be folded into the baseline, and databases that adopt it would never run it.
|
|
1094
|
+
- The package ends in a single leaf, and no earlier baseline is waiting unreleased (see second resets below).
|
|
1095
|
+
- The deleted history holds nothing a fresh database would miss. `RunPython`, `RunSQL`, any custom operation, and anything on the database side of `SeparateDatabaseAndState` are listed and refused until each carries `skip_on_reset=True` — "a fresh database can do without this." If it can't (a seed, an extension), move the effect somewhere a fresh database does get it, then mark it.
|
|
1096
|
+
- The migrations directory is committed — tracked, unmodified, inside a git repository (so an installed package in site-packages cannot be reset; the check applies to `--dry-run` too). The leaf becomes the baseline's sentinel, so it cannot be something you created a minute ago. No check can prove every environment applied it — the command prints that obligation, and a database that hasn't is refused until it does. If anything goes wrong the output has already printed the one line that puts it back: `git checkout -- <migrations dir> && rm <the new baseline>`.
|
|
1097
|
+
- The result loads: dependencies on retired names resolve, the graph has no cycle, the baseline reproduces the models exactly.
|
|
1098
|
+
- Nothing the baseline needs is defined inside a migration file. A custom field class or callable written in the history disappears with it; move it into the app first.
|
|
648
1099
|
|
|
649
|
-
|
|
650
|
-
- The first migration is named `0001_initial` (the default). If it has a different name, this workflow won't work cleanly.
|
|
1100
|
+
Options:
|
|
651
1101
|
|
|
652
|
-
|
|
1102
|
+
- `--shipped-in <version>` — the release this reset ships in, named in the refusal a database that missed the leaf gets, and required before the package can be reset again. Plain's own packages leave it empty and fill it in at release time (a repository test enforces it, and the package's `plain.postgres` minimum is raised to the first release that understands baselines); an app should pass the version or deploy this ships in.
|
|
1103
|
+
- `--dry-run` — print the baseline and the deletion list, write nothing.
|
|
653
1104
|
|
|
654
|
-
|
|
655
|
-
2. Delete every file in the package's `migrations/` directory except `__init__.py`.
|
|
656
|
-
3. Run `plain migrations create` to generate a fresh `0001_initial`.
|
|
657
|
-
4. Run `plain migrations prune --yes` to remove stale DB records. The existing `0001_initial` record matches the new file, so the database is immediately up to date.
|
|
658
|
-
5. Verify with `plain postgres schema` (zero issues means the reset is clean) and `plain migrations create --check` (no pending changes).
|
|
659
|
-
6. Commit and deploy. On every other environment, run `plain migrations prune --yes`. No actual SQL runs — it only cleans up migration history records. If `migrations prune` is already in your deploy steps, no changes are needed.
|
|
1105
|
+
**Dependencies.** The baseline depends on each other package its models reference, at the earliest migration of that package where the referenced models exist — and on nothing else. A package that another package pinned early _and_ whose models now point back at that package cannot get a single root: the graph would be a cycle, and the command refuses with it. That is a limit of migration graphs, not of the command; nothing to do with a second package fixes it.
|
|
660
1106
|
|
|
661
|
-
**
|
|
1107
|
+
**Support boundaries.** Adoption checks that the sentinel is recorded and the tables exist, not columns; editing history behind a released leaf is outside what any check can catch. A data migration in _another_ package that read this package's historical state (a field the baseline no longer has) keeps loading but can fail on a fresh database; the command cannot see it.
|
|
662
1108
|
|
|
663
|
-
|
|
664
|
-
- Data migrations (`RunPython`) in the deleted history are gone, which is fine since they've already run everywhere.
|
|
665
|
-
- If CI runs `migrations create --check` or `migrations apply --check`, the reset PR must be merged and deployed before those checks pass in other branches.
|
|
1109
|
+
**Second reset.** Run it again later and the previous baseline joins `retired`. It refuses while the previous baseline's `shipped_in` is empty: nothing shipped it yet, so superseding its name would strand every database still at the original sentinel — once that baseline has shipped everywhere, set its `shipped_in` to the version that shipped it and reset again; otherwise restore the history and reset once.
|
|
666
1110
|
|
|
667
1111
|
### Data migrations
|
|
668
1112
|
|
|
@@ -685,7 +1129,9 @@ def forwards(models, schema_editor):
|
|
|
685
1129
|
|
|
686
1130
|
For large tables, chunk the work (e.g. by ID range) and commit between batches so no single transaction holds locks for too long.
|
|
687
1131
|
|
|
688
|
-
|
|
1132
|
+
When the package's history is later collapsed (see [Resetting migrations](#resetting-migrations)), `plain migrations reset` refuses while any `RunPython`/`RunSQL` is unmarked. Pass `skip_on_reset=True` once a fresh database can do without its effect; if it can't, move the effect into a seed first.
|
|
1133
|
+
|
|
1134
|
+
See [Structural migrations](#structural-migrations) for shared commands (`apply`, `list`, `prune`).
|
|
689
1135
|
|
|
690
1136
|
#### Cascading deletes inside data migrations
|
|
691
1137
|
|
|
@@ -718,9 +1164,9 @@ Convergence compares the indexes, constraints, foreign keys, nullability, and [s
|
|
|
718
1164
|
```python
|
|
719
1165
|
@postgres.register_model
|
|
720
1166
|
class User(postgres.Model):
|
|
721
|
-
email: str = types.EmailField()
|
|
722
|
-
username: str = types.TextField(max_length=150)
|
|
723
|
-
age: int = types.IntegerField()
|
|
1167
|
+
email: Field[str] = types.EmailField()
|
|
1168
|
+
username: Field[str] = types.TextField(max_length=150)
|
|
1169
|
+
age: Field[int] = types.IntegerField()
|
|
724
1170
|
|
|
725
1171
|
model_options = postgres.Options(
|
|
726
1172
|
indexes=[
|
|
@@ -829,24 +1275,24 @@ from decimal import Decimal
|
|
|
829
1275
|
from datetime import datetime
|
|
830
1276
|
|
|
831
1277
|
from plain import postgres
|
|
832
|
-
from plain.postgres import types
|
|
1278
|
+
from plain.postgres import Field, types
|
|
833
1279
|
|
|
834
1280
|
|
|
835
1281
|
class Product(postgres.Model):
|
|
836
1282
|
# Text fields
|
|
837
|
-
name: str = types.TextField(max_length=200)
|
|
838
|
-
description: str = types.TextField()
|
|
1283
|
+
name: Field[str] = types.TextField(max_length=200)
|
|
1284
|
+
description: Field[str] = types.TextField()
|
|
839
1285
|
|
|
840
1286
|
# Numeric fields
|
|
841
|
-
price: Decimal = types.DecimalField(max_digits=10, decimal_places=2)
|
|
842
|
-
quantity: int = types.IntegerField(default=0)
|
|
1287
|
+
price: Field[Decimal] = types.DecimalField(max_digits=10, decimal_places=2)
|
|
1288
|
+
quantity: Field[int] = types.IntegerField(default=0)
|
|
843
1289
|
|
|
844
1290
|
# Boolean fields
|
|
845
|
-
is_active: bool = types.BooleanField(default=True)
|
|
1291
|
+
is_active: Field[bool] = types.BooleanField(default=True)
|
|
846
1292
|
|
|
847
1293
|
# Date and time fields
|
|
848
|
-
created_at: datetime = types.DateTimeField(create_now=True)
|
|
849
|
-
updated_at: datetime = types.DateTimeField(update_now=True)
|
|
1294
|
+
created_at: Field[datetime] = types.DateTimeField(create_now=True)
|
|
1295
|
+
updated_at: Field[datetime] = types.DateTimeField(update_now=True)
|
|
850
1296
|
```
|
|
851
1297
|
|
|
852
1298
|
**Text fields:**
|
|
@@ -889,43 +1335,81 @@ See [Encrypted fields](#encrypted-fields) for details.
|
|
|
889
1335
|
|
|
890
1336
|
For relationship fields, see [Relationships](#relationships).
|
|
891
1337
|
|
|
892
|
-
For nullable fields, use `| None` in the annotation
|
|
1338
|
+
For nullable fields, use `| None` in the annotation and `default=None` to make
|
|
1339
|
+
the field optional in the constructor:
|
|
893
1340
|
|
|
894
1341
|
```python
|
|
895
|
-
published_at: datetime | None = types.DateTimeField(
|
|
1342
|
+
published_at: Field[datetime | None] = types.DateTimeField(
|
|
1343
|
+
allow_null=True, required=False, default=None
|
|
1344
|
+
)
|
|
896
1345
|
```
|
|
897
1346
|
|
|
898
1347
|
### Sharing fields across models
|
|
899
1348
|
|
|
900
|
-
To share common fields across multiple models, use Python classes as mixins.
|
|
1349
|
+
To share common fields across multiple models, use Python classes as mixins. A mixin that declares fields must inherit `postgres.ModelMixin`, and the final, registered model must inherit directly from `postgres.Model` (the mixins must not).
|
|
901
1350
|
|
|
902
1351
|
```python
|
|
903
1352
|
from datetime import datetime
|
|
904
1353
|
|
|
905
1354
|
from plain import postgres
|
|
906
|
-
from plain.postgres import types
|
|
1355
|
+
from plain.postgres import Field, ModelMixin, types
|
|
907
1356
|
|
|
908
1357
|
|
|
909
1358
|
# Regular Python class for shared fields
|
|
910
|
-
class TimestampedMixin:
|
|
911
|
-
created_at: datetime = types.DateTimeField(create_now=True)
|
|
912
|
-
updated_at: datetime = types.DateTimeField(update_now=True)
|
|
1359
|
+
class TimestampedMixin(ModelMixin):
|
|
1360
|
+
created_at: Field[datetime] = types.DateTimeField(create_now=True)
|
|
1361
|
+
updated_at: Field[datetime] = types.DateTimeField(update_now=True)
|
|
1362
|
+
source: Field[str] = types.TextField(max_length=50, required=False, default="")
|
|
913
1363
|
|
|
914
1364
|
|
|
915
1365
|
# Models inherit from the mixin AND postgres.Model
|
|
916
1366
|
@postgres.register_model
|
|
917
1367
|
class User(TimestampedMixin, postgres.Model):
|
|
918
|
-
email: str = types.EmailField()
|
|
919
|
-
password = PasswordField()
|
|
920
|
-
is_admin: bool = types.BooleanField(default=False)
|
|
1368
|
+
email: Field[str] = types.EmailField()
|
|
1369
|
+
password: Field[str] = PasswordField()
|
|
1370
|
+
is_admin: Field[bool] = types.BooleanField(default=False)
|
|
921
1371
|
|
|
922
1372
|
|
|
923
1373
|
@postgres.register_model
|
|
924
1374
|
class Note(TimestampedMixin, postgres.Model):
|
|
925
|
-
content: str = types.TextField(max_length=1024)
|
|
926
|
-
liked: bool = types.BooleanField(default=False)
|
|
1375
|
+
content: Field[str] = types.TextField(max_length=1024)
|
|
1376
|
+
liked: Field[bool] = types.BooleanField(default=False)
|
|
1377
|
+
```
|
|
1378
|
+
|
|
1379
|
+
`ModelMixin` is what puts the mixin's fields into each model's typed
|
|
1380
|
+
constructor. The runtime collects fields off the whole MRO either way, but a
|
|
1381
|
+
mixin inheriting nothing isn't visible to PEP 681, so the checker would reject
|
|
1382
|
+
`Note(source="import")` on code that runs fine. `ModelMixin` carries no runtime
|
|
1383
|
+
behavior — it's the same transform models get, and mixins are still declarations
|
|
1384
|
+
you never instantiate directly.
|
|
1385
|
+
|
|
1386
|
+
That is also the one way to keep a **custom field type** required in the
|
|
1387
|
+
constructor. A package that ships its own field type can declare the field on a
|
|
1388
|
+
mixin carrying a transform that lists its constructor, and models mixing it in
|
|
1389
|
+
get the field with its requiredness intact:
|
|
1390
|
+
|
|
1391
|
+
```python
|
|
1392
|
+
from typing import dataclass_transform
|
|
1393
|
+
|
|
1394
|
+
from plain.postgres import Field
|
|
1395
|
+
from plain.passwords.types import PasswordField
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
@dataclass_transform(kw_only_default=True, field_specifiers=(PasswordField,))
|
|
1399
|
+
class _PasswordFieldSpec: ...
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
class PasswordMixin(_PasswordFieldSpec):
|
|
1403
|
+
password: Field[str] = PasswordField()
|
|
927
1404
|
```
|
|
928
1405
|
|
|
1406
|
+
A model mixing in `PasswordMixin` now gets `password` as a required constructor
|
|
1407
|
+
argument, so `User(email="a@b.com")` is a type error again. The specifier list
|
|
1408
|
+
has to name every constructor the mixin's own body uses, core ones included.
|
|
1409
|
+
Declared in the model's own body instead, `password` stays optional to the
|
|
1410
|
+
checker — a model body is governed by the specifier list `plain.postgres`
|
|
1411
|
+
declares, which a package outside it can't extend.
|
|
1412
|
+
|
|
929
1413
|
### Encrypted fields
|
|
930
1414
|
|
|
931
1415
|
Encrypted fields transparently encrypt values before writing to the database and decrypt on read. Use them for third-party credentials, API keys, OAuth tokens, and other secrets your application needs back in plaintext.
|
|
@@ -934,28 +1418,46 @@ This is **not** for passwords or tokens you issue — those should be hashed (on
|
|
|
934
1418
|
|
|
935
1419
|
```python
|
|
936
1420
|
from plain import postgres
|
|
937
|
-
from plain.postgres import types
|
|
1421
|
+
from plain.postgres import EncryptedField, Field, types
|
|
938
1422
|
|
|
939
1423
|
|
|
940
1424
|
@postgres.register_model
|
|
941
1425
|
class Integration(postgres.Model):
|
|
942
|
-
name: str = types.TextField(max_length=100)
|
|
943
|
-
api_key: str = types.EncryptedTextField(max_length=200)
|
|
944
|
-
credentials: dict = types.EncryptedJSONField(
|
|
1426
|
+
name: Field[str] = types.TextField(max_length=100)
|
|
1427
|
+
api_key: EncryptedField[str] = types.EncryptedTextField(max_length=200)
|
|
1428
|
+
credentials: EncryptedField[dict | None] = types.EncryptedJSONField(
|
|
1429
|
+
required=False, allow_null=True, default=None
|
|
1430
|
+
)
|
|
945
1431
|
```
|
|
946
1432
|
|
|
1433
|
+
Annotate encrypted fields `EncryptedField[T]`, not `Field[T]`. The annotation is
|
|
1434
|
+
what the type checker reads, and `EncryptedField[T]` is the `Field[T]` subclass
|
|
1435
|
+
that declares the blocked conditions — with a plain `Field[T]`,
|
|
1436
|
+
`Integration.api_key.equals("x")` type-checks its way to a runtime `TypeError`
|
|
1437
|
+
instead of being rejected at the call site. It types the constructor exactly as
|
|
1438
|
+
`Field[T]` does.
|
|
1439
|
+
|
|
947
1440
|
Values are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) with a key derived from `SECRET_KEY`. The `cryptography` package is required — install it with `pip install cryptography`.
|
|
948
1441
|
|
|
949
1442
|
**Available fields:**
|
|
950
1443
|
|
|
1444
|
+
- `EncryptedField[T]` — the annotation type; also the shared base the two fields below derive from.
|
|
951
1445
|
- `EncryptedTextField` — encrypts text, stored as `text` in the database regardless of `max_length` (ciphertext is longer than plaintext). `max_length` is enforced on the plaintext value during validation.
|
|
952
1446
|
- `EncryptedJSONField` — serializes to JSON, encrypts, and stores as `text`. Supports custom `encoder` and `decoder` parameters (same as `JSONField`).
|
|
953
1447
|
|
|
954
1448
|
**Limitations:**
|
|
955
1449
|
|
|
956
|
-
- **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.
|
|
1450
|
+
- **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. Comparing against a value raises `TypeError` rather than silently matching nothing — both `filter(api_key="x")` and the typed [condition methods](#typed-conditions-with-where) (`equals`, `contains`, …), which are also rejected at the call site when the field is annotated `EncryptedField[T]`. `filter(api_key=None)` still rewrites to `IS NULL`.
|
|
1451
|
+
- **`get_or_create()` must not look up an encrypted field.** `get_or_create(api_key="k")` raises, and the error says to move the value into `defaults=`. This is a deliberate break: it previously "worked" by creating a new row on every call, because the lookup could never match existing ciphertext. An encrypted value can be written, just not looked up:
|
|
1452
|
+
|
|
1453
|
+
```python
|
|
1454
|
+
Integration.query.get_or_create(name="acme", defaults={"api_key": "k"})
|
|
1455
|
+
```
|
|
1456
|
+
|
|
1457
|
+
The same applies to `upsert()`, and to an expression right-hand side like `filter(api_key=F("name"))` — the column is still ciphertext.
|
|
1458
|
+
|
|
957
1459
|
- **No indexes or constraints** — encrypted fields cannot be used in indexes or unique constraints. Preflight checks will catch this.
|
|
958
|
-
- **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`
|
|
1460
|
+
- **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` has no persistent default at all — even `{}` serializes to text that would need ciphertext — so pair `allow_null=True` with `default=None`, which stores nothing and just marks the field optional in the constructor.
|
|
959
1461
|
|
|
960
1462
|
**Key rotation:**
|
|
961
1463
|
|
|
@@ -971,12 +1473,12 @@ Use [`ForeignKeyField`](./fields/related.py#ForeignKeyField) for many-to-one and
|
|
|
971
1473
|
|
|
972
1474
|
```python
|
|
973
1475
|
from plain import postgres
|
|
974
|
-
from plain.postgres import types
|
|
1476
|
+
from plain.postgres import Field, types
|
|
975
1477
|
|
|
976
1478
|
|
|
977
1479
|
@postgres.register_model
|
|
978
1480
|
class Book(postgres.Model):
|
|
979
|
-
title: str = types.TextField(max_length=200)
|
|
1481
|
+
title: Field[str] = types.TextField(max_length=200)
|
|
980
1482
|
author: Author = types.ForeignKeyField("Author", on_delete=postgres.CASCADE)
|
|
981
1483
|
tags = types.ManyToManyField("Tag")
|
|
982
1484
|
```
|
|
@@ -994,6 +1496,8 @@ book.author.name # one query — loads the rest of the row
|
|
|
994
1496
|
|
|
995
1497
|
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.
|
|
996
1498
|
|
|
1499
|
+
A foreign key with no value raises `RelatedObjectDoesNotExist` on access. That attribute still lives on the descriptor at runtime, but class-level access is now typed as the related model (that is what makes `Book.author.name.equals(...)` work), so `Book.author.RelatedObjectDoesNotExist` is a type error. Catch it as `Author.DoesNotExist` — the exception subclasses both that and `AttributeError` — or as `AttributeError`.
|
|
1500
|
+
|
|
997
1501
|
The partial-instance shortcut is safe because Plain always creates a database foreign-key constraint, so the referenced row is guaranteed to exist.
|
|
998
1502
|
|
|
999
1503
|
### Constraints are checked immediately
|
|
@@ -1009,21 +1513,26 @@ A migration can add a column, backfill it in `RunPython`, and drop or alter colu
|
|
|
1009
1513
|
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):
|
|
1010
1514
|
|
|
1011
1515
|
```python
|
|
1516
|
+
from typing import ClassVar
|
|
1517
|
+
|
|
1012
1518
|
from plain import postgres
|
|
1013
|
-
from plain.postgres import types
|
|
1519
|
+
from plain.postgres import Field, types
|
|
1014
1520
|
|
|
1015
1521
|
|
|
1016
1522
|
@postgres.register_model
|
|
1017
1523
|
class Author(postgres.Model):
|
|
1018
|
-
name: str = types.TextField(max_length=200)
|
|
1019
|
-
# Explicit reverse accessor for all books by this author
|
|
1020
|
-
|
|
1524
|
+
name: Field[str] = types.TextField(max_length=200)
|
|
1525
|
+
# Explicit reverse accessor for all books by this author.
|
|
1526
|
+
# ClassVar keeps it out of the typed constructor (it's an accessor, not a field).
|
|
1527
|
+
books: ClassVar[types.ReverseForeignKey[Book]] = types.ReverseForeignKey(
|
|
1528
|
+
to="Book", field="author"
|
|
1529
|
+
)
|
|
1021
1530
|
|
|
1022
1531
|
|
|
1023
1532
|
@postgres.register_model
|
|
1024
1533
|
class Book(postgres.Model):
|
|
1025
|
-
title: str = types.TextField(max_length=200)
|
|
1026
|
-
author: Author = types.ForeignKeyField(Author, on_delete=postgres.CASCADE)
|
|
1534
|
+
title: Field[str] = types.TextField(max_length=200)
|
|
1535
|
+
author: Field[Author] = types.ForeignKeyField(Author, on_delete=postgres.CASCADE)
|
|
1027
1536
|
|
|
1028
1537
|
|
|
1029
1538
|
# Usage
|
|
@@ -1040,14 +1549,16 @@ For many-to-many relationships:
|
|
|
1040
1549
|
```python
|
|
1041
1550
|
@postgres.register_model
|
|
1042
1551
|
class Feature(postgres.Model):
|
|
1043
|
-
name: str = types.TextField(max_length=100)
|
|
1552
|
+
name: Field[str] = types.TextField(max_length=100)
|
|
1044
1553
|
# Explicit reverse accessor for all cars with this feature
|
|
1045
|
-
cars = types.ReverseManyToMany(
|
|
1554
|
+
cars: ClassVar[types.ReverseManyToMany[Car]] = types.ReverseManyToMany(
|
|
1555
|
+
to="Car", field="features"
|
|
1556
|
+
)
|
|
1046
1557
|
|
|
1047
1558
|
|
|
1048
1559
|
@postgres.register_model
|
|
1049
1560
|
class Car(postgres.Model):
|
|
1050
|
-
model: str = types.TextField(max_length=100)
|
|
1561
|
+
model: Field[str] = types.TextField(max_length=100)
|
|
1051
1562
|
features = types.ManyToManyField(Feature)
|
|
1052
1563
|
|
|
1053
1564
|
|
|
@@ -1066,16 +1577,16 @@ for car in feature.cars.all():
|
|
|
1066
1577
|
|
|
1067
1578
|
Reverse relations are optional — if you don't declare them, the automatic `{model}_set` accessor still works.
|
|
1068
1579
|
|
|
1069
|
-
To get type checking for custom QuerySet methods
|
|
1580
|
+
Annotate reverse relations with `ClassVar` — they're class-level accessors, not constructor fields, so `ClassVar` keeps them out of the typed `Model(...)` constructor (same as `query`). To get type checking for custom QuerySet methods, specify the QuerySet type as a second parameter:
|
|
1070
1581
|
|
|
1071
1582
|
```python
|
|
1072
1583
|
# Basic usage
|
|
1073
|
-
books: types.ReverseForeignKey[Book] = types.ReverseForeignKey(
|
|
1584
|
+
books: ClassVar[types.ReverseForeignKey[Book]] = types.ReverseForeignKey(
|
|
1074
1585
|
to="Book", field="author"
|
|
1075
1586
|
)
|
|
1076
1587
|
|
|
1077
1588
|
# With custom QuerySet for proper method recognition
|
|
1078
|
-
books: types.ReverseForeignKey[Book, BookQuerySet] = types.ReverseForeignKey(
|
|
1589
|
+
books: ClassVar[types.ReverseForeignKey[Book, BookQuerySet]] = types.ReverseForeignKey(
|
|
1079
1590
|
to="Book", field="author"
|
|
1080
1591
|
)
|
|
1081
1592
|
|
|
@@ -1092,8 +1603,8 @@ author.books.query.published()
|
|
|
1092
1603
|
```python
|
|
1093
1604
|
@postgres.register_model
|
|
1094
1605
|
class User(postgres.Model):
|
|
1095
|
-
email: str = types.EmailField()
|
|
1096
|
-
age: int = types.IntegerField()
|
|
1606
|
+
email: Field[str] = types.EmailField()
|
|
1607
|
+
age: Field[int] = types.IntegerField()
|
|
1097
1608
|
|
|
1098
1609
|
model_options = postgres.Options(
|
|
1099
1610
|
constraints=[
|
|
@@ -1131,7 +1642,7 @@ except (psycopg.IntegrityError, ValidationError):
|
|
|
1131
1642
|
... # lost a race — reload and retry, or report it
|
|
1132
1643
|
```
|
|
1133
1644
|
|
|
1134
|
-
For a plain insert-or-update with no per-row logic, `
|
|
1645
|
+
For a plain insert-or-update with no per-row logic there's no race to catch in the first place: `upsert(**values, unique_fields=[...])` for one row and `bulk_upsert(objs, update_fields=[...], unique_fields=[...])` for many are each a single atomic statement.
|
|
1135
1646
|
|
|
1136
1647
|
### Indexes and constraints
|
|
1137
1648
|
|
|
@@ -1139,9 +1650,9 @@ You can optimize queries and ensure data integrity with indexes and constraints.
|
|
|
1139
1650
|
|
|
1140
1651
|
```python
|
|
1141
1652
|
class User(postgres.Model):
|
|
1142
|
-
email: str = types.EmailField()
|
|
1143
|
-
username: str = types.TextField(max_length=150)
|
|
1144
|
-
age: int = types.IntegerField()
|
|
1653
|
+
email: Field[str] = types.EmailField()
|
|
1654
|
+
username: Field[str] = types.TextField(max_length=150)
|
|
1655
|
+
age: Field[int] = types.IntegerField()
|
|
1145
1656
|
|
|
1146
1657
|
model_options = postgres.Options(
|
|
1147
1658
|
indexes=[
|
|
@@ -1216,14 +1727,14 @@ Add indexes for columns that appear in `.filter()`, `.order_by()`, or `.exclude(
|
|
|
1216
1727
|
```python
|
|
1217
1728
|
# Bad — full table scan on every filtered query
|
|
1218
1729
|
class Order(postgres.Model):
|
|
1219
|
-
status: str = types.TextField(max_length=20)
|
|
1220
|
-
created_at: datetime = types.DateTimeField()
|
|
1730
|
+
status: Field[str] = types.TextField(max_length=20)
|
|
1731
|
+
created_at: Field[datetime] = types.DateTimeField()
|
|
1221
1732
|
|
|
1222
1733
|
|
|
1223
1734
|
# Good — indexed for common queries
|
|
1224
1735
|
class Order(postgres.Model):
|
|
1225
|
-
status: str = types.TextField(max_length=20)
|
|
1226
|
-
created_at: datetime = types.DateTimeField()
|
|
1736
|
+
status: Field[str] = types.TextField(max_length=20)
|
|
1737
|
+
created_at: Field[datetime] = types.DateTimeField()
|
|
1227
1738
|
|
|
1228
1739
|
model_options = postgres.Options(
|
|
1229
1740
|
indexes=[postgres.Index(fields=["status", "-created_at"])],
|
|
@@ -1268,10 +1779,10 @@ Use `default=""` instead of `allow_null=True` to avoid two representations of "e
|
|
|
1268
1779
|
|
|
1269
1780
|
```python
|
|
1270
1781
|
# Bad — NULL and "" both mean "empty"
|
|
1271
|
-
nickname: str = types.TextField(max_length=50, allow_null=True)
|
|
1782
|
+
nickname: Field[str] = types.TextField(max_length=50, allow_null=True)
|
|
1272
1783
|
|
|
1273
1784
|
# Good — single empty representation
|
|
1274
|
-
nickname: str = types.TextField(max_length=50, default="")
|
|
1785
|
+
nickname: Field[str] = types.TextField(max_length=50, default="")
|
|
1275
1786
|
```
|
|
1276
1787
|
|
|
1277
1788
|
## Forms
|