plain.postgres 0.118.0__tar.gz → 0.120.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.118.0 → plain_postgres-0.120.0}/PKG-INFO +700 -89
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/CHANGELOG.md +47 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/README.md +699 -88
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/__init__.py +17 -3
- plain_postgres-0.120.0/plain/postgres/agents/.claude/rules/plain-postgres.md +178 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/base.py +133 -12
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/constants.py +0 -1
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/dialect.py +45 -24
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/expressions.py +106 -3
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/__init__.py +0 -2
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/base.py +335 -21
- plain_postgres-0.120.0/plain/postgres/fields/encrypted.py +483 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/related.py +9 -4
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/related_descriptors.py +107 -45
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/related_managers.py +54 -19
- plain_postgres-0.120.0/plain/postgres/fields/related_typed.py +167 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/reverse_descriptors.py +1 -1
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/temporal.py +5 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/uuid.py +5 -1
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/meta.py +8 -0
- plain_postgres-0.120.0/plain/postgres/middleware.py +45 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/autodetector.py +1 -1
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/options.py +11 -0
- plain_postgres-0.120.0/plain/postgres/preflight/models.py +704 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/query.py +1675 -195
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/query_utils.py +102 -1
- plain_postgres-0.120.0/plain/postgres/selectable.py +20 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/sql/__init__.py +2 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/sql/compiler.py +246 -94
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/sql/datastructures.py +0 -4
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/sql/query.py +31 -19
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/types.py +4 -4
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/types.pyi +78 -21
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/pyproject.toml +1 -1
- plain_postgres-0.120.0/tests/app/examples/migrations/0019_shadowtarget_shadowsource.py +34 -0
- plain_postgres-0.120.0/tests/app/examples/migrations/0020_stringconditionsexample.py +21 -0
- plain_postgres-0.120.0/tests/app/examples/migrations/0021_returningevent.py +19 -0
- plain_postgres-0.120.0/tests/app/examples/migrations/0022_upsertitem.py +19 -0
- plain_postgres-0.120.0/tests/app/examples/migrations/0023_upsertpair.py +21 -0
- plain_postgres-0.120.0/tests/app/examples/migrations/0024_upserttenant_upsertvaluekey_upsertscoped.py +43 -0
- plain_postgres-0.120.0/tests/app/examples/migrations/0025_upsertfloatkey.py +20 -0
- plain_postgres-0.120.0/tests/app/examples/migrations/0026_upsertdecimalkey.py +20 -0
- plain_postgres-0.120.0/tests/app/examples/migrations/0027_upsertstamped.py +34 -0
- plain_postgres-0.120.0/tests/app/examples/migrations/0028_aliascollisionexample.py +24 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/models/__init__.py +5 -0
- plain_postgres-0.120.0/tests/app/examples/models/alias_collisions.py +19 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/models/constraints.py +3 -5
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/models/defaults.py +12 -13
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/models/delete.py +37 -42
- plain_postgres-0.120.0/tests/app/examples/models/encrypted.py +17 -0
- plain_postgres-0.120.0/tests/app/examples/models/forms.py +36 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/models/indexes.py +3 -5
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/models/iteration.py +3 -5
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/models/mixins.py +7 -7
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/models/nullability.py +2 -4
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/models/querysets.py +8 -8
- plain_postgres-0.120.0/tests/app/examples/models/relationships.py +48 -0
- plain_postgres-0.120.0/tests/app/examples/models/returning.py +18 -0
- plain_postgres-0.120.0/tests/app/examples/models/shadowing.py +24 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/models/storage_parameters.py +2 -4
- plain_postgres-0.120.0/tests/app/examples/models/string_conditions.py +20 -0
- plain_postgres-0.120.0/tests/app/examples/models/trees.py +15 -0
- plain_postgres-0.120.0/tests/app/examples/models/upsert.py +160 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/conftest.py +22 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_autodetector_not_null_errors.py +2 -2
- plain_postgres-0.120.0/tests/internal/test_conflict_lock_order.py +90 -0
- plain_postgres-0.120.0/tests/internal/test_conflict_sort_value.py +134 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_connection_lifecycle.py +62 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_db_expression_defaults.py +35 -12
- plain_postgres-0.120.0/tests/internal/test_encrypted_internals.py +85 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_fk_characterization.py +3 -3
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_literal_default_persistence.py +19 -1
- plain_postgres-0.120.0/tests/internal/test_lock_mode_sql.py +17 -0
- plain_postgres-0.120.0/tests/internal/test_m2m_value_from_object.py +39 -0
- plain_postgres-0.120.0/tests/internal/test_meta_related_objects.py +41 -0
- plain_postgres-0.120.0/tests/internal/test_preflight_fk_annotation.py +585 -0
- plain_postgres-0.120.0/tests/internal/test_preflight_nullable_default.py +109 -0
- plain_postgres-0.120.0/tests/internal/test_random_string_sql.py +42 -0
- plain_postgres-0.120.0/tests/internal/test_returning_internals.py +47 -0
- plain_postgres-0.120.0/tests/internal/test_returning_order.py +54 -0
- plain_postgres-0.120.0/tests/internal/test_stub_runtime_conformance.py +285 -0
- plain_postgres-0.120.0/tests/internal/test_typed_construction_preflight.py +82 -0
- plain_postgres-0.120.0/tests/internal/test_typed_where_internals.py +204 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_unresolved_relation_refs.py +5 -1
- plain_postgres-0.120.0/tests/public/test_bulk_upsert.py +726 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_delete_behaviors.py +7 -5
- plain_postgres-0.120.0/tests/public/test_encrypted_fields.py +302 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_field_defaults.py +1 -1
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_integrity_error_mapping.py +4 -2
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_m2m.py +45 -32
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_manager_assignment.py +4 -2
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_manual_pk.py +2 -2
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_order_by_expressions.py +1 -1
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_random_string_field.py +0 -32
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_related.py +6 -37
- plain_postgres-0.120.0/tests/public/test_returning.py +667 -0
- plain_postgres-0.120.0/tests/public/test_row_locking.py +312 -0
- plain_postgres-0.120.0/tests/public/test_select.py +914 -0
- plain_postgres-0.120.0/tests/public/test_typed_where.py +439 -0
- plain_postgres-0.120.0/tests/public/test_typed_where_fk.py +412 -0
- plain_postgres-0.120.0/tests/public/test_upsert.py +754 -0
- plain_postgres-0.120.0/tests/typing/README.md +65 -0
- plain_postgres-0.120.0/tests/typing/bulk_writes.py +83 -0
- plain_postgres-0.120.0/tests/typing/conditions_encrypted.py +125 -0
- plain_postgres-0.120.0/tests/typing/conditions_value_types.py +118 -0
- plain_postgres-0.120.0/tests/typing/construction_mixins.py +39 -0
- plain_postgres-0.120.0/tests/typing/construction_required_fields.py +57 -0
- plain_postgres-0.120.0/tests/typing/construction_unknown_kwargs.py +58 -0
- plain_postgres-0.120.0/tests/typing/construction_value_types.py +38 -0
- plain_postgres-0.120.0/tests/typing/field_access.py +43 -0
- plain_postgres-0.120.0/tests/typing/field_constructors.py +86 -0
- plain_postgres-0.120.0/tests/typing/queryset_access.py +46 -0
- plain_postgres-0.120.0/tests/typing/relations_foreign_key.py +76 -0
- plain_postgres-0.120.0/tests/typing/relations_reverse.py +26 -0
- plain_postgres-0.120.0/tests/typing/relations_string_reference.py +135 -0
- plain_postgres-0.120.0/tests/typing/returning_writes.py +117 -0
- plain_postgres-0.120.0/tests/typing/select_ladder.py +116 -0
- plain_postgres-0.120.0/tests/typing/select_rows.py +250 -0
- plain_postgres-0.120.0/tests/typing/upsert_writes.py +38 -0
- plain_postgres-0.118.0/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -102
- plain_postgres-0.118.0/plain/postgres/fields/encrypted.py +0 -294
- plain_postgres-0.118.0/plain/postgres/middleware.py +0 -37
- plain_postgres-0.118.0/plain/postgres/preflight/models.py +0 -198
- plain_postgres-0.118.0/tests/app/examples/models/encrypted.py +0 -17
- plain_postgres-0.118.0/tests/app/examples/models/forms.py +0 -32
- plain_postgres-0.118.0/tests/app/examples/models/relationships.py +0 -43
- plain_postgres-0.118.0/tests/app/examples/models/trees.py +0 -17
- plain_postgres-0.118.0/tests/public/test_encrypted_fields.py +0 -176
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/.gitignore +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/CLAUDE.md +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/LICENSE +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/README.md +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/adapters.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/cli/decorators.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/cli/sync.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/connection.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/constraints.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/convergence/analysis.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/convergence/corrections.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/convergence/planning.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/databases.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/binary.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/__init__.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/context.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/helpers.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/ownership.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/runner.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/types.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/introspection/schema.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/baselines.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/reset.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/preflight/__init__.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/preflight/database.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/preflight/indexes.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/schema_lock.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/sources.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/test/database.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/conftest.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_apply_replan.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_baselines.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_connection_isolation.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_connection_pool.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_connection_self_heal.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_constraint_violation_error.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_convergence.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_constraints.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_defaults.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_fk.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_indexes.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_nullability.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_timeouts.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_databases_not_on_runtime_path.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_diagnose.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_executor_connection_hook.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_health.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_introspection.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_management_connection.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_migration_executor.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_migrations_reset.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_otel_metrics.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_replaces_removed.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_rollback_exc_attribution.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_schema_lock.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_schema_normalize_type.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_schema_timeouts.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_shipped_baselines.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/internal/test_writer_operation_options.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_create_update.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_database_url.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_databases.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_deferred_loading.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_exceptions.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_functions_uuid.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_iterator.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_mixins.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_modelform_roundtrip.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_only_empty_defaults.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_queryset_ordered.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_queryset_repr.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_queryset_slicing.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_raw_query.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.0}/tests/public/test_read_only_transactions.py +0 -0
- {plain_postgres-0.118.0 → plain_postgres-0.120.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.120.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,53 @@ 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.
|
|
65
|
+
|
|
66
|
+
Annotating is not per-model opt-in. `postgres.Model` carries the transform, so
|
|
67
|
+
the checker synthesizes every subclass's constructor from its annotated
|
|
68
|
+
attributes and nothing else. Drop the annotation from `email` above and it stops
|
|
69
|
+
being a constructor argument at all — `User(email="a@b.com")` is then rejected as
|
|
70
|
+
an unknown argument. The runtime doesn't care either way, but if you run a type
|
|
71
|
+
checker, every model has to be annotated.
|
|
72
|
+
|
|
73
|
+
A field is optional in that constructor only when its definition passes
|
|
74
|
+
`default=` (this is general, not nullable-specific — a `required=False` field
|
|
75
|
+
with no `default=` is still a required constructor arg), so nullable fields use
|
|
76
|
+
`Field[T | None]` with `default=None`. The runtime already treats a nullable
|
|
77
|
+
field as optional — constructing without it yields `None` — so `default=None`
|
|
78
|
+
exists for the checker; it persists nothing and changes no schema.
|
|
79
|
+
`plain preflight` lists the nullable fields still missing one
|
|
80
|
+
(`postgres.nullable_field_without_default`). DB-owned fields (`id`,
|
|
81
|
+
`create_now`, generated values) are auto-excluded from the constructor.
|
|
82
|
+
|
|
83
|
+
Field types declared outside `plain.postgres.types` — `PasswordField`, or one of
|
|
84
|
+
your own — are the exception: their stub still types the value, but they are
|
|
85
|
+
always _optional_ in the constructor, even when the column is `NOT NULL`. PEP
|
|
86
|
+
681 recognizes a field declaration by matching the constructor against a fixed
|
|
87
|
+
list, and that list is baked into `plain.postgres` where a third-party field
|
|
88
|
+
type can't join it, so the checker reads the assignment as a plain default
|
|
89
|
+
value. Wrong value types and unknown field names are still caught; only
|
|
90
|
+
requiredness is lost, and omitting one surfaces as a `NOT NULL` error on insert
|
|
91
|
+
instead. [Sharing fields across models](#sharing-fields-across-models) shows how
|
|
92
|
+
a package that ships a field type can keep it required.
|
|
93
|
+
|
|
61
94
|
Every model automatically includes an `id` field which serves as the primary
|
|
62
95
|
key. The name `id` is reserved and can't be used for other fields.
|
|
63
96
|
|
|
@@ -191,13 +224,203 @@ first_10_users = User.query.all()[:10]
|
|
|
191
224
|
|
|
192
225
|
For more advanced querying options, see the [`QuerySet`](./query.py#QuerySet) class.
|
|
193
226
|
|
|
227
|
+
### Typed conditions with where()
|
|
228
|
+
|
|
229
|
+
`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:
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
from datetime import datetime
|
|
233
|
+
|
|
234
|
+
from plain import postgres
|
|
235
|
+
from plain.postgres import Field, types
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@postgres.register_model
|
|
239
|
+
class User(postgres.Model):
|
|
240
|
+
email: Field[str] = types.EmailField()
|
|
241
|
+
role: Field[str] = types.TextField(max_length=20)
|
|
242
|
+
age: Field[int | None] = types.IntegerField(allow_null=True, default=None)
|
|
243
|
+
created_at: Field[datetime] = types.DateTimeField(create_now=True)
|
|
244
|
+
updated_at: Field[datetime] = types.DateTimeField(update_now=True)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# Each argument is a condition; multiple arguments are ANDed together.
|
|
248
|
+
admins = User.query.where(
|
|
249
|
+
User.role.equals("admin"),
|
|
250
|
+
User.age.gte(18),
|
|
251
|
+
)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Every field exposes `equals`, `not_equal`, `gt`, `gte`, `lt`, `lte`, `is_null`, and `is_in`. Text fields add `contains`, `startswith`, and `endswith`, plus their case-insensitive forms `icontains`, `istartswith`, `iendswith`, and `iequals` (the typed spelling of `filter(field__iexact=...)`). Each returns a `Q`, so you can combine them with `|` and `&` or negate with `~`:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
# Membership, negation, and OR
|
|
258
|
+
User.query.where(User.role.is_in(["admin", "staff"]))
|
|
259
|
+
User.query.where(~User.role.equals("guest"))
|
|
260
|
+
User.query.where(User.email.endswith("@example.com") | User.role.equals("admin"))
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`~` negates whatever it wraps, which is what you want for a composite condition but not for a null check. `is_null()` takes a flag, and the two compile differently:
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
User.query.where(~User.age.is_null()) # WHERE NOT ("age" IS NULL)
|
|
267
|
+
User.query.where(User.age.is_null(False)) # WHERE "age" IS NOT NULL
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Both match the same rows. `is_null(False)` is the conversion for `filter(age__isnull=False)` and emits the SQL you would write by hand, so reach for it and keep `~` for negating a condition that isn't a null check.
|
|
271
|
+
|
|
272
|
+
A comparison can also take another column of the same value type, instead of a value:
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
# Q(updated_at__gt=F("created_at"))
|
|
276
|
+
User.query.where(User.updated_at.gt(User.created_at))
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`equals`, `not_equal`, `gt`, `gte`, `lt`, and `lte` all accept one. The column has to belong to the same model, which `where()` checks on both sides, and it has to be the same value type — comparing an `int` column against a `str` one is a type error.
|
|
280
|
+
|
|
281
|
+
A **nullable column is a different value type** to the checker — `Field[int | None]` is not a `Field[int]` — so the two directions aren't the same. A non-null column accepts a nullable one on the right; a nullable one on the left won't accept a non-null column, because there's no way to name its value type without the `None`. Compare the other way round, or drop to `filter(age__lt=F("other"))`.
|
|
282
|
+
|
|
283
|
+
An `F()` expression works too, but that arm is untyped: an expression's output type isn't tracked, so nothing checks it against the column. `F()` is the same escape hatch here that it is in `filter()`.
|
|
284
|
+
|
|
285
|
+
**A condition _is_ a `Q`**, so these replace `Q` everywhere, not just in `filter()`. Anything that takes a `Q` takes one unchanged — `When()`, including the ones inside a `Case()`, and an aggregate's `filter=`:
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
from plain.postgres.aggregates import Count
|
|
289
|
+
from plain.postgres.expressions import Case, Value, When
|
|
290
|
+
|
|
291
|
+
User.query.annotate(
|
|
292
|
+
tier=Case(When(User.age.gte(18), then=Value("adult")), default=Value("minor"))
|
|
293
|
+
)
|
|
294
|
+
User.query.values("role").annotate(adults=Count("id", filter=User.age.gte(18)))
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
```sql
|
|
298
|
+
-- the Case annotation
|
|
299
|
+
CASE WHEN "age" >= %s THEN %s ELSE %s END AS "tier"
|
|
300
|
+
-- the Count annotation
|
|
301
|
+
COUNT("id") FILTER (WHERE "age" >= %s) AS "adults"
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Conditions traverse foreign keys — accessing a field through a relation builds the joined lookup:
|
|
305
|
+
|
|
306
|
+
```python
|
|
307
|
+
# Q(author__email="a@example.com")
|
|
308
|
+
Post.query.where(Post.author.email.equals("a@example.com"))
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
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:
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
Post.query.where(Post.author.id.equals(author.id))
|
|
315
|
+
Post.query.where(Post.author.id.is_in([a.id for a in authors]))
|
|
316
|
+
Post.query.where(Post.author.id.is_null()) # nullable relation
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
`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.
|
|
320
|
+
|
|
321
|
+
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)`.
|
|
322
|
+
|
|
323
|
+
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.
|
|
324
|
+
|
|
325
|
+
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.
|
|
326
|
+
|
|
327
|
+
**`where()` preserves the order you wrote; `filter()` sorted its kwargs alphabetically.** Converting a multi-condition `filter()` can change the WHERE clause text and the parameter order, though not which rows it matches:
|
|
328
|
+
|
|
329
|
+
```sql
|
|
330
|
+
-- filter(role="admin", email="a@example.com")
|
|
331
|
+
WHERE ("email" = %s AND "role" = %s) -- params: ('a@example.com', 'admin')
|
|
332
|
+
-- where(User.role.equals("admin"), User.email.equals("a@example.com"))
|
|
333
|
+
WHERE ("role" = %s AND "email" = %s) -- params: ('admin', 'a@example.com')
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Kwargs you already wrote alphabetically convert unchanged. Where the source order wasn't alphabetical, preserving it changes the predicate structure — a test asserting on generated SQL notices, and so can `pg_stat_statements`, which groups by structure rather than literal text and files the reordered statement as a new entry.
|
|
337
|
+
|
|
338
|
+
**Conditions are strict where `filter()` was lenient — to the type checker.** `equals` takes the field's value type, so `User.age.equals("18")` and a `Field[UUID]`'s `.equals("3f2504e0-4f89-11d3-9a0c-0305e82c3301")` are type errors where `filter(age="18")` and `filter(uuid="3f2504e0-4f89-11d3-9a0c-0305e82c3301")` were not. Runtime coercion is unchanged: `User.age.equals("18")` still coerces the string and runs, exactly as the kwarg did.
|
|
339
|
+
|
|
340
|
+
So the strictness lands on the caller. Code holding a string from a CLI argument, URL segment, or session parses it first — `int(raw)`, `uuid.UUID(raw)` — and an invalid value raises `ValueError` there, before the ORM is involved, next to the input that was wrong.
|
|
341
|
+
|
|
342
|
+
**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.
|
|
343
|
+
|
|
344
|
+
[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`. That holds on either side of a comparison: an encrypted column can't be the column another column is compared _against_ either, since that would match plaintext against ciphertext.
|
|
345
|
+
|
|
346
|
+
### Selecting columns with select()
|
|
347
|
+
|
|
348
|
+
`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:
|
|
349
|
+
|
|
350
|
+
```python
|
|
351
|
+
from plain.postgres import Field, types
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@postgres.register_model
|
|
355
|
+
class User(postgres.Model):
|
|
356
|
+
email: Field[str] = types.EmailField()
|
|
357
|
+
age: Field[int | None] = types.IntegerField(allow_null=True, default=None)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# list-like of tuple[str, int | None], precisely typed
|
|
361
|
+
rows = User.query.where(User.age.gte(18)).select(User.email, User.age)
|
|
362
|
+
for email, age in rows:
|
|
363
|
+
...
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
There are three modes:
|
|
367
|
+
|
|
368
|
+
- **Tuples** (default) — one tuple per row, typed per column: `select(User.email, User.age)` yields `tuple[str, int | None]`.
|
|
369
|
+
- **Flat scalars** — a single column unwrapped, with `flat=True`: `select(User.email, flat=True)` yields `str`. `flat=True` accepts exactly one column.
|
|
370
|
+
- **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.
|
|
371
|
+
|
|
372
|
+
```python
|
|
373
|
+
from dataclasses import dataclass
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@dataclass
|
|
377
|
+
class UserStats:
|
|
378
|
+
email: str
|
|
379
|
+
age: int | None
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
stats = User.query.select(User.email, User.age, result_type=UserStats)
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
The return value is a [`RowQuerySet`](./query.py#RowQuerySet) — that is the name to reach for when you need to annotate one:
|
|
386
|
+
|
|
387
|
+
```python
|
|
388
|
+
from plain.postgres import RowQuerySet
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def adults() -> RowQuerySet[tuple[str, int | None]]:
|
|
392
|
+
return User.query.where(User.age.gte(18)).select(User.email, User.age)
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
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]`.
|
|
396
|
+
|
|
397
|
+
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.
|
|
398
|
+
|
|
399
|
+
`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.
|
|
400
|
+
|
|
401
|
+
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.
|
|
402
|
+
|
|
403
|
+
`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()`.
|
|
404
|
+
|
|
405
|
+
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`.
|
|
406
|
+
|
|
407
|
+
**`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.
|
|
408
|
+
|
|
409
|
+
`select()` takes typed references only — a bare string like `select("email")` raises `TypeError` (use `User.email`).
|
|
410
|
+
|
|
411
|
+
**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.
|
|
412
|
+
|
|
413
|
+
**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.
|
|
414
|
+
|
|
415
|
+
**`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`.
|
|
416
|
+
|
|
194
417
|
### Custom QuerySets
|
|
195
418
|
|
|
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:
|
|
419
|
+
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
420
|
|
|
198
421
|
```python
|
|
199
|
-
from typing import Self
|
|
200
|
-
from plain.postgres import types
|
|
422
|
+
from typing import ClassVar, Self
|
|
423
|
+
from plain.postgres import Field, types
|
|
201
424
|
|
|
202
425
|
|
|
203
426
|
class PublishedQuerySet(postgres.QuerySet["Article"]):
|
|
@@ -210,10 +433,10 @@ class PublishedQuerySet(postgres.QuerySet["Article"]):
|
|
|
210
433
|
|
|
211
434
|
@postgres.register_model
|
|
212
435
|
class Article(postgres.Model):
|
|
213
|
-
title: str = types.TextField(max_length=200)
|
|
214
|
-
status: str = types.TextField(max_length=20)
|
|
436
|
+
title: Field[str] = types.TextField(max_length=200)
|
|
437
|
+
status: Field[str] = types.TextField(max_length=20)
|
|
215
438
|
|
|
216
|
-
query = PublishedQuerySet()
|
|
439
|
+
query: ClassVar[PublishedQuerySet] = PublishedQuerySet()
|
|
217
440
|
|
|
218
441
|
|
|
219
442
|
# Usage - all methods available on Article.query
|
|
@@ -233,24 +456,11 @@ special_qs = SpecialQuerySet.from_model(Article)
|
|
|
233
456
|
|
|
234
457
|
### Typing QuerySets
|
|
235
458
|
|
|
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.
|
|
459
|
+
`Model.query` is typed automatically — `User.query.get()` returns a `User` and
|
|
460
|
+
`User.query.filter()` returns `QuerySet[User]` with no extra annotation. Don't
|
|
461
|
+
redeclare `query` just to type it; the base provides `QuerySet[Self]`. Declare
|
|
462
|
+
`query` only when attaching a **custom** QuerySet, and then as a `ClassVar`
|
|
463
|
+
(see [Custom QuerySets](#custom-querysets) above).
|
|
254
464
|
|
|
255
465
|
### Raw SQL
|
|
256
466
|
|
|
@@ -410,6 +620,176 @@ for name in names:
|
|
|
410
620
|
Tag.query.bulk_create([Tag(name=name) for name in names])
|
|
411
621
|
```
|
|
412
622
|
|
|
623
|
+
`bulk_create` is insert-only. To insert new rows and update the ones that
|
|
624
|
+
already exist in a single statement, use `bulk_upsert` (below).
|
|
625
|
+
|
|
626
|
+
#### Use `bulk_upsert` to insert-or-update in one statement
|
|
627
|
+
|
|
628
|
+
`bulk_upsert(objs, *, update_fields, unique_fields, batch_size=None)` issues one
|
|
629
|
+
`INSERT ... ON CONFLICT (unique_fields) DO UPDATE SET ... RETURNING` per batch.
|
|
630
|
+
Rows that don't exist yet are inserted; rows that collide on `unique_fields` have
|
|
631
|
+
their `update_fields` overwritten. You get back the objects you passed in, in the
|
|
632
|
+
order you passed them (a new list — `objs` itself is never reordered), each with
|
|
633
|
+
its DB-generated fields (primary key, DB defaults) populated.
|
|
634
|
+
|
|
635
|
+
```python
|
|
636
|
+
# Insert new items, refresh `value`/`expires_at` on any existing key.
|
|
637
|
+
CachedItem.query.bulk_upsert(
|
|
638
|
+
[CachedItem(key=k, value=v, expires_at=exp) for k, v in items],
|
|
639
|
+
update_fields=[CachedItem.value, CachedItem.expires_at],
|
|
640
|
+
unique_fields=[CachedItem.key],
|
|
641
|
+
)
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
- `update_fields` and `unique_fields` take field references (`Model.field`), not
|
|
645
|
+
strings. A foreign key is named by the relation itself — `Model.tenant`, which
|
|
646
|
+
resolves to the `tenant_id` column. (This is the one write API that takes
|
|
647
|
+
`Model.fk`. `returning()` refuses it, because there it would be ambiguous with
|
|
648
|
+
asking for the whole related object; here a column list can only mean the
|
|
649
|
+
column.)
|
|
650
|
+
- `unique_fields` must name the **primary key** or a `UniqueConstraint` declared
|
|
651
|
+
on the model (no condition, no expressions) — this is the conflict target. A
|
|
652
|
+
unique `Index` is not enough; declare a `UniqueConstraint`.
|
|
653
|
+
- `update_fields` must be concrete, non-primary-key, must not name the same
|
|
654
|
+
column twice (Postgres assigns each column once per statement), and must not
|
|
655
|
+
overlap `unique_fields`. A column the database fills in (`create_now`,
|
|
656
|
+
`generate=True`, `RandomStringField`) can't be named either — the update would
|
|
657
|
+
overwrite the stored value with a freshly evaluated default.
|
|
658
|
+
- Every object must have a non-null value for every unique field. `NULL` never
|
|
659
|
+
conflicts in Postgres, so it can't be upserted. A database-generated column
|
|
660
|
+
(`create_now`, `generate=True`, `RandomStringField`) can't be a unique field
|
|
661
|
+
either — your objects never hold its value, so it could never conflict. Nor
|
|
662
|
+
can an `update_now=True` column, which is stamped again on every write.
|
|
663
|
+
- **Two objects with the same unique key in one batch raise `ValueError`.**
|
|
664
|
+
Postgres won't touch a row twice in one statement. Split across batches it's
|
|
665
|
+
allowed — the first inserts, the second updates, and the later write wins.
|
|
666
|
+
- **`update_now=True` columns are refreshed on a conflict automatically.** You
|
|
667
|
+
don't name them in `update_fields`; a row that gets updated gets a fresh
|
|
668
|
+
stamp, and the object handed back carries the same one.
|
|
669
|
+
- **An `id` you set is kept on the insert path; on a conflict the stored row
|
|
670
|
+
wins.** A new row is written with the `id` you gave it. A conflicting one
|
|
671
|
+
already has an `id`, and that is the one hydrated back onto your object — the
|
|
672
|
+
row in the table is the truth. An `id` that collides with a _different_ row
|
|
673
|
+
raises `psycopg.errors.UniqueViolation`, like any set-based write.
|
|
674
|
+
- Every object is sorted by its conflict key before anything is sent, so
|
|
675
|
+
concurrent `bulk_upsert` calls over overlapping keys lock rows in the same
|
|
676
|
+
order and can't deadlock each other. Returned rows are mapped onto the objects
|
|
677
|
+
by position, exactly as `bulk_create` does.
|
|
678
|
+
- Like `bulk_create`, the write is against the table: a filter on the queryset
|
|
679
|
+
you call it from doesn't narrow or exclude anything.
|
|
680
|
+
|
|
681
|
+
For a single row, reach for `upsert` (below) instead — it returns the object and
|
|
682
|
+
a `created` flag rather than a list.
|
|
683
|
+
|
|
684
|
+
#### Use `upsert` for a single insert-or-update
|
|
685
|
+
|
|
686
|
+
`upsert(*, unique_fields, defaults=None, create_defaults=None, conflict_defaults=None, **kwargs)`
|
|
687
|
+
is the single-row counterpart to `bulk_upsert`. It runs one
|
|
688
|
+
`INSERT ... ON CONFLICT (unique_fields) DO UPDATE SET ... RETURNING` statement and
|
|
689
|
+
returns `(obj, created)` — `created` is `True` when a new row was inserted,
|
|
690
|
+
`False` when the conflicting row was updated. The object is hydrated from the
|
|
691
|
+
post-write row, so there's no second query.
|
|
692
|
+
|
|
693
|
+
```python
|
|
694
|
+
# Insert the flag, or refresh used_at on the existing one.
|
|
695
|
+
flag, created = Flag.query.upsert(
|
|
696
|
+
name="beta-dashboard",
|
|
697
|
+
defaults={"used_at": timezone.now()},
|
|
698
|
+
unique_fields=[Flag.name],
|
|
699
|
+
)
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
`unique_fields` takes field references (`Model.field`), like `bulk_upsert`. The
|
|
703
|
+
value sources below stay string-keyed — they follow the `kwargs` idiom.
|
|
704
|
+
|
|
705
|
+
Value sources, lowest precedence first — where the same key appears in two of
|
|
706
|
+
them, `create_defaults` loses to `defaults`, which loses to `**kwargs`:
|
|
707
|
+
|
|
708
|
+
- `create_defaults` is applied on **insert only** — extras that must not change
|
|
709
|
+
when the row already exists.
|
|
710
|
+
- `defaults` and `**kwargs` are applied on **both** insert and conflict-update.
|
|
711
|
+
`kwargs` carries the identifying values (including the unique fields).
|
|
712
|
+
- `conflict_defaults` applies to the `DO UPDATE SET` **only** — it never changes
|
|
713
|
+
the inserted row. A value can be a plain value or an expression, so
|
|
714
|
+
`{"count": F("count") + 1}` is an atomic counter that reads the existing row:
|
|
715
|
+
|
|
716
|
+
```python
|
|
717
|
+
view, created = PageView.query.upsert(
|
|
718
|
+
path="/home",
|
|
719
|
+
conflict_defaults={"count": F("count") + 1},
|
|
720
|
+
unique_fields=[PageView.path],
|
|
721
|
+
)
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
Two expressions reach two different rows inside a conflict update. `F("count")`
|
|
725
|
+
reads the row already **stored**; `Excluded("count")` reads the row the INSERT
|
|
726
|
+
**proposed**, compiling to `EXCLUDED."count"`. Combine them to accumulate the
|
|
727
|
+
incoming value instead of overwriting it — the whole statement is one atomic
|
|
728
|
+
`UPDATE`, so concurrent callers each add their own delta:
|
|
729
|
+
|
|
730
|
+
```python
|
|
731
|
+
from plain.postgres import Excluded, F
|
|
732
|
+
|
|
733
|
+
# Add this batch's 7 views to whatever is already stored.
|
|
734
|
+
view, created = PageView.query.upsert(
|
|
735
|
+
path="/home",
|
|
736
|
+
count=7,
|
|
737
|
+
conflict_defaults={"count": F("count") + Excluded("count")},
|
|
738
|
+
unique_fields=[PageView.path],
|
|
739
|
+
)
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
`Excluded()` is only meaningful while the conflict update's assignments are
|
|
743
|
+
being built. Anywhere else raises a `FieldError` — including as an inserted
|
|
744
|
+
value in `kwargs`/`defaults`/`create_defaults` of the very same call, where it
|
|
745
|
+
would be naming the row being written, and in `filter()`, `update()` or
|
|
746
|
+
`annotate()`.
|
|
747
|
+
|
|
748
|
+
On conflict the `SET` clause covers:
|
|
749
|
+
|
|
750
|
+
- every non-unique, non-PK column from `kwargs`/`defaults`, each taking the value
|
|
751
|
+
the INSERT proposed;
|
|
752
|
+
- every `DateTimeField(update_now=True)` column, whose fresh `pre_save()`
|
|
753
|
+
timestamp rides along in the INSERT and would otherwise go stale (a
|
|
754
|
+
`create_now`-only column is _not_ in the `SET`, so it keeps its original
|
|
755
|
+
value);
|
|
756
|
+
- every column `conflict_defaults` names — replacing the proposed value when the
|
|
757
|
+
column is already in the `SET`, adding it when it isn't.
|
|
758
|
+
|
|
759
|
+
Columns nobody wrote are left alone, and `create_defaults` never take part in the
|
|
760
|
+
update. Naming a **database-owned** column (`create_now`, `generate=True`,
|
|
761
|
+
`RandomStringField`) in `kwargs`/`defaults` is an error rather than a silent
|
|
762
|
+
reset: `EXCLUDED` carries a freshly evaluated default, so the conflict update
|
|
763
|
+
would overwrite the stored creation timestamp every time. A column that's also
|
|
764
|
+
`update_now` is exempt — refreshing it is the point. Unlike `bulk_upsert`, `upsert` derives its `SET` columns rather than
|
|
765
|
+
taking them, so a `conflict_defaults` key may not name a unique field — that's
|
|
766
|
+
the conflict target.
|
|
767
|
+
|
|
768
|
+
Every value source resolves callables, and every key must name a **column** — a
|
|
769
|
+
settable property is refused, since the `SET` clause is derived from columns and
|
|
770
|
+
a property could only ever be written on the insert half.
|
|
771
|
+
|
|
772
|
+
`unique_fields` must name a `UniqueConstraint` declared on the model (no
|
|
773
|
+
condition, no expressions) and every unique field must be non-null. It can't be
|
|
774
|
+
the primary key: Postgres generates the identity value, so a caller has nothing
|
|
775
|
+
to conflict on.
|
|
776
|
+
|
|
777
|
+
Three things to keep in mind:
|
|
778
|
+
|
|
779
|
+
- **`kwargs` are values, not filters.** A keyword that isn't part of the conflict
|
|
780
|
+
key doesn't narrow which row is matched — `unique_fields` alone decides that —
|
|
781
|
+
it's just another column written to whichever row conflicts.
|
|
782
|
+
- **The queryset's filters don't scope it either.** `qs.filter(...).upsert(...)`
|
|
783
|
+
writes the conflicting row whether or not it matches the filter — the conflict
|
|
784
|
+
constraint decides which row is touched. It isn't refused because the
|
|
785
|
+
related-manager wrappers call through a filtered queryset. To scope an upsert,
|
|
786
|
+
fold the scoping column into `unique_fields` (and into the constraint), or do a
|
|
787
|
+
locked read and write instead.
|
|
788
|
+
- **The merged row isn't validated**, and a constraint violation surfaces as a raw
|
|
789
|
+
`psycopg.IntegrityError`, not a `ValidationError`. `upsert` looks single-row
|
|
790
|
+
like `create()`, but it's a set-based write like `bulk_upsert` (see
|
|
791
|
+
[Validation](#validation)).
|
|
792
|
+
|
|
413
793
|
#### Use queryset `.update()` / `.delete()` for mass operations
|
|
414
794
|
|
|
415
795
|
```python
|
|
@@ -450,6 +830,44 @@ for row in HugeTable.query.iterator(chunk_size=2000):
|
|
|
450
830
|
process(row)
|
|
451
831
|
```
|
|
452
832
|
|
|
833
|
+
## Returning affected rows
|
|
834
|
+
|
|
835
|
+
`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.
|
|
836
|
+
|
|
837
|
+
```python
|
|
838
|
+
# No arguments: rows come back as model instances.
|
|
839
|
+
running = Job.query.filter(status="pending").returning().update(status="running")
|
|
840
|
+
for job in running:
|
|
841
|
+
print(job.id, job.status) # reflects the post-update values
|
|
842
|
+
|
|
843
|
+
# Field references: rows come back as dicts of just those columns.
|
|
844
|
+
deleted = (
|
|
845
|
+
Event.query.filter(created_at__lt=cutoff)
|
|
846
|
+
.returning(Event.id, Event.payload)
|
|
847
|
+
.delete()
|
|
848
|
+
)
|
|
849
|
+
for row in deleted:
|
|
850
|
+
print(row["id"], row["payload"]) # the rows as they were deleted
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
- **`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.
|
|
854
|
+
- 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.
|
|
855
|
+
- **`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.
|
|
856
|
+
- **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.
|
|
857
|
+
- Without `returning()`, `update()`/`delete()` return an `int` as before.
|
|
858
|
+
- `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.
|
|
859
|
+
- `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.
|
|
860
|
+
|
|
861
|
+
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).
|
|
862
|
+
|
|
863
|
+
`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.
|
|
864
|
+
|
|
865
|
+
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.
|
|
866
|
+
|
|
867
|
+
`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.
|
|
868
|
+
|
|
869
|
+
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.
|
|
870
|
+
|
|
453
871
|
## Transactions
|
|
454
872
|
|
|
455
873
|
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 +927,81 @@ with read_only():
|
|
|
509
927
|
User.query.count() # still works — outer txn is healthy
|
|
510
928
|
```
|
|
511
929
|
|
|
930
|
+
### Row-level locking
|
|
931
|
+
|
|
932
|
+
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:
|
|
933
|
+
|
|
934
|
+
```python
|
|
935
|
+
with transaction.atomic():
|
|
936
|
+
account = Account.query.for_update().get(id=1) # FOR UPDATE
|
|
937
|
+
account.balance -= 100
|
|
938
|
+
account.update(fields=["balance"])
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
| Method | SQL clause | Use it when |
|
|
942
|
+
| --------------------- | ------------------- | -------------------------------------------------------------------------- |
|
|
943
|
+
| `for_update()` | `FOR UPDATE` | You intend to update or delete the row. |
|
|
944
|
+
| `for_no_key_update()` | `FOR NO KEY UPDATE` | Same, but you won't touch the primary key — lets key-share locks proceed. |
|
|
945
|
+
| `for_share()` | `FOR SHARE` | You need the row to stay put while you read it, but others may also share. |
|
|
946
|
+
| `for_key_share()` | `FOR KEY SHARE` | Weakest — only blocks changes to the row's key. |
|
|
947
|
+
|
|
948
|
+
Locking requires an open transaction. The method itself just builds the queryset — evaluating a locked queryset outside `transaction.atomic()` is what raises `TransactionManagementError`.
|
|
949
|
+
|
|
950
|
+
All four accept the same options:
|
|
951
|
+
|
|
952
|
+
- `nowait=True` — raise instead of waiting if a row is already locked.
|
|
953
|
+
- `skip_locked=True` — skip already-locked rows instead of waiting (can't be combined with `nowait`).
|
|
954
|
+
- `of=("self", "related")` — lock only the named tables in a join rather than every selected row.
|
|
955
|
+
|
|
956
|
+
```python
|
|
957
|
+
# Claim the next available job without blocking on rows another worker holds
|
|
958
|
+
job = Job.query.for_update(skip_locked=True).filter(status="pending").first()
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
Chaining more than one lock method keeps only the last one, options included.
|
|
962
|
+
|
|
963
|
+
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:
|
|
964
|
+
|
|
965
|
+
```python
|
|
966
|
+
Widget.query.distinct().for_update() # NotSupportedError
|
|
967
|
+
Widget.query.for_update().distinct() # same error, either way round
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
`count()` and `aggregate()` are the exception — they compile to an aggregate query of their own, so they drop the lock rather than reject it.
|
|
971
|
+
|
|
972
|
+
#### Locking a set-based write
|
|
973
|
+
|
|
974
|
+
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:
|
|
975
|
+
|
|
976
|
+
```python
|
|
977
|
+
# Claim *all* pending rows matching the filter, in one statement
|
|
978
|
+
with transaction.atomic():
|
|
979
|
+
claimed = (
|
|
980
|
+
Job.query.filter(status="pending")
|
|
981
|
+
.for_update(skip_locked=True)
|
|
982
|
+
.returning()
|
|
983
|
+
.update(status="running")
|
|
984
|
+
)
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
```sql
|
|
988
|
+
UPDATE "jobs" SET "status" = 'running'
|
|
989
|
+
WHERE "id" IN (
|
|
990
|
+
SELECT U0."id" FROM "jobs" U0 WHERE U0."status" = 'pending' FOR UPDATE OF U0 SKIP LOCKED
|
|
991
|
+
)
|
|
992
|
+
RETURNING ...
|
|
993
|
+
```
|
|
994
|
+
|
|
995
|
+
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.
|
|
996
|
+
|
|
997
|
+
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.
|
|
998
|
+
|
|
999
|
+
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.
|
|
1000
|
+
|
|
1001
|
+
**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.
|
|
1002
|
+
|
|
1003
|
+
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.
|
|
1004
|
+
|
|
512
1005
|
## Schema management
|
|
513
1006
|
|
|
514
1007
|
Schema changes fall into three categories, each with a different author and apply model:
|
|
@@ -743,9 +1236,9 @@ Convergence compares the indexes, constraints, foreign keys, nullability, and [s
|
|
|
743
1236
|
```python
|
|
744
1237
|
@postgres.register_model
|
|
745
1238
|
class User(postgres.Model):
|
|
746
|
-
email: str = types.EmailField()
|
|
747
|
-
username: str = types.TextField(max_length=150)
|
|
748
|
-
age: int = types.IntegerField()
|
|
1239
|
+
email: Field[str] = types.EmailField()
|
|
1240
|
+
username: Field[str] = types.TextField(max_length=150)
|
|
1241
|
+
age: Field[int] = types.IntegerField()
|
|
749
1242
|
|
|
750
1243
|
model_options = postgres.Options(
|
|
751
1244
|
indexes=[
|
|
@@ -854,24 +1347,24 @@ from decimal import Decimal
|
|
|
854
1347
|
from datetime import datetime
|
|
855
1348
|
|
|
856
1349
|
from plain import postgres
|
|
857
|
-
from plain.postgres import types
|
|
1350
|
+
from plain.postgres import Field, types
|
|
858
1351
|
|
|
859
1352
|
|
|
860
1353
|
class Product(postgres.Model):
|
|
861
1354
|
# Text fields
|
|
862
|
-
name: str = types.TextField(max_length=200)
|
|
863
|
-
description: str = types.TextField()
|
|
1355
|
+
name: Field[str] = types.TextField(max_length=200)
|
|
1356
|
+
description: Field[str] = types.TextField()
|
|
864
1357
|
|
|
865
1358
|
# Numeric fields
|
|
866
|
-
price: Decimal = types.DecimalField(max_digits=10, decimal_places=2)
|
|
867
|
-
quantity: int = types.IntegerField(default=0)
|
|
1359
|
+
price: Field[Decimal] = types.DecimalField(max_digits=10, decimal_places=2)
|
|
1360
|
+
quantity: Field[int] = types.IntegerField(default=0)
|
|
868
1361
|
|
|
869
1362
|
# Boolean fields
|
|
870
|
-
is_active: bool = types.BooleanField(default=True)
|
|
1363
|
+
is_active: Field[bool] = types.BooleanField(default=True)
|
|
871
1364
|
|
|
872
1365
|
# Date and time fields
|
|
873
|
-
created_at: datetime = types.DateTimeField(create_now=True)
|
|
874
|
-
updated_at: datetime = types.DateTimeField(update_now=True)
|
|
1366
|
+
created_at: Field[datetime] = types.DateTimeField(create_now=True)
|
|
1367
|
+
updated_at: Field[datetime] = types.DateTimeField(update_now=True)
|
|
875
1368
|
```
|
|
876
1369
|
|
|
877
1370
|
**Text fields:**
|
|
@@ -914,43 +1407,81 @@ See [Encrypted fields](#encrypted-fields) for details.
|
|
|
914
1407
|
|
|
915
1408
|
For relationship fields, see [Relationships](#relationships).
|
|
916
1409
|
|
|
917
|
-
For nullable fields, use `| None` in the annotation
|
|
1410
|
+
For nullable fields, use `| None` in the annotation and `default=None` to make
|
|
1411
|
+
the field optional in the constructor:
|
|
918
1412
|
|
|
919
1413
|
```python
|
|
920
|
-
published_at: datetime | None = types.DateTimeField(
|
|
1414
|
+
published_at: Field[datetime | None] = types.DateTimeField(
|
|
1415
|
+
allow_null=True, required=False, default=None
|
|
1416
|
+
)
|
|
921
1417
|
```
|
|
922
1418
|
|
|
923
1419
|
### Sharing fields across models
|
|
924
1420
|
|
|
925
|
-
To share common fields across multiple models, use Python classes as mixins.
|
|
1421
|
+
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).
|
|
926
1422
|
|
|
927
1423
|
```python
|
|
928
1424
|
from datetime import datetime
|
|
929
1425
|
|
|
930
1426
|
from plain import postgres
|
|
931
|
-
from plain.postgres import types
|
|
1427
|
+
from plain.postgres import Field, ModelMixin, types
|
|
932
1428
|
|
|
933
1429
|
|
|
934
1430
|
# Regular Python class for shared fields
|
|
935
|
-
class TimestampedMixin:
|
|
936
|
-
created_at: datetime = types.DateTimeField(create_now=True)
|
|
937
|
-
updated_at: datetime = types.DateTimeField(update_now=True)
|
|
1431
|
+
class TimestampedMixin(ModelMixin):
|
|
1432
|
+
created_at: Field[datetime] = types.DateTimeField(create_now=True)
|
|
1433
|
+
updated_at: Field[datetime] = types.DateTimeField(update_now=True)
|
|
1434
|
+
source: Field[str] = types.TextField(max_length=50, required=False, default="")
|
|
938
1435
|
|
|
939
1436
|
|
|
940
1437
|
# Models inherit from the mixin AND postgres.Model
|
|
941
1438
|
@postgres.register_model
|
|
942
1439
|
class User(TimestampedMixin, postgres.Model):
|
|
943
|
-
email: str = types.EmailField()
|
|
944
|
-
password = PasswordField()
|
|
945
|
-
is_admin: bool = types.BooleanField(default=False)
|
|
1440
|
+
email: Field[str] = types.EmailField()
|
|
1441
|
+
password: Field[str] = PasswordField()
|
|
1442
|
+
is_admin: Field[bool] = types.BooleanField(default=False)
|
|
946
1443
|
|
|
947
1444
|
|
|
948
1445
|
@postgres.register_model
|
|
949
1446
|
class Note(TimestampedMixin, postgres.Model):
|
|
950
|
-
content: str = types.TextField(max_length=1024)
|
|
951
|
-
liked: bool = types.BooleanField(default=False)
|
|
1447
|
+
content: Field[str] = types.TextField(max_length=1024)
|
|
1448
|
+
liked: Field[bool] = types.BooleanField(default=False)
|
|
1449
|
+
```
|
|
1450
|
+
|
|
1451
|
+
`ModelMixin` is what puts the mixin's fields into each model's typed
|
|
1452
|
+
constructor. The runtime collects fields off the whole MRO either way, but a
|
|
1453
|
+
mixin inheriting nothing isn't visible to PEP 681, so the checker would reject
|
|
1454
|
+
`Note(source="import")` on code that runs fine. `ModelMixin` carries no runtime
|
|
1455
|
+
behavior — it's the same transform models get, and mixins are still declarations
|
|
1456
|
+
you never instantiate directly.
|
|
1457
|
+
|
|
1458
|
+
That is also the one way to keep a **custom field type** required in the
|
|
1459
|
+
constructor. A package that ships its own field type can declare the field on a
|
|
1460
|
+
mixin carrying a transform that lists its constructor, and models mixing it in
|
|
1461
|
+
get the field with its requiredness intact:
|
|
1462
|
+
|
|
1463
|
+
```python
|
|
1464
|
+
from typing import dataclass_transform
|
|
1465
|
+
|
|
1466
|
+
from plain.postgres import Field
|
|
1467
|
+
from plain.passwords.types import PasswordField
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
@dataclass_transform(kw_only_default=True, field_specifiers=(PasswordField,))
|
|
1471
|
+
class _PasswordFieldSpec: ...
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
class PasswordMixin(_PasswordFieldSpec):
|
|
1475
|
+
password: Field[str] = PasswordField()
|
|
952
1476
|
```
|
|
953
1477
|
|
|
1478
|
+
A model mixing in `PasswordMixin` now gets `password` as a required constructor
|
|
1479
|
+
argument, so `User(email="a@b.com")` is a type error again. The specifier list
|
|
1480
|
+
has to name every constructor the mixin's own body uses, core ones included.
|
|
1481
|
+
Declared in the model's own body instead, `password` stays optional to the
|
|
1482
|
+
checker — a model body is governed by the specifier list `plain.postgres`
|
|
1483
|
+
declares, which a package outside it can't extend.
|
|
1484
|
+
|
|
954
1485
|
### Encrypted fields
|
|
955
1486
|
|
|
956
1487
|
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.
|
|
@@ -959,28 +1490,46 @@ This is **not** for passwords or tokens you issue — those should be hashed (on
|
|
|
959
1490
|
|
|
960
1491
|
```python
|
|
961
1492
|
from plain import postgres
|
|
962
|
-
from plain.postgres import types
|
|
1493
|
+
from plain.postgres import EncryptedField, Field, types
|
|
963
1494
|
|
|
964
1495
|
|
|
965
1496
|
@postgres.register_model
|
|
966
1497
|
class Integration(postgres.Model):
|
|
967
|
-
name: str = types.TextField(max_length=100)
|
|
968
|
-
api_key: str = types.EncryptedTextField(max_length=200)
|
|
969
|
-
credentials: dict = types.EncryptedJSONField(
|
|
1498
|
+
name: Field[str] = types.TextField(max_length=100)
|
|
1499
|
+
api_key: EncryptedField[str] = types.EncryptedTextField(max_length=200)
|
|
1500
|
+
credentials: EncryptedField[dict | None] = types.EncryptedJSONField(
|
|
1501
|
+
required=False, allow_null=True, default=None
|
|
1502
|
+
)
|
|
970
1503
|
```
|
|
971
1504
|
|
|
1505
|
+
Annotate encrypted fields `EncryptedField[T]`, not `Field[T]`. The annotation is
|
|
1506
|
+
what the type checker reads, and `EncryptedField[T]` is the `Field[T]` subclass
|
|
1507
|
+
that declares the blocked conditions — with a plain `Field[T]`,
|
|
1508
|
+
`Integration.api_key.equals("x")` type-checks its way to a runtime `TypeError`
|
|
1509
|
+
instead of being rejected at the call site. It types the constructor exactly as
|
|
1510
|
+
`Field[T]` does.
|
|
1511
|
+
|
|
972
1512
|
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`.
|
|
973
1513
|
|
|
974
1514
|
**Available fields:**
|
|
975
1515
|
|
|
1516
|
+
- `EncryptedField[T]` — the annotation type; also the shared base the two fields below derive from.
|
|
976
1517
|
- `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.
|
|
977
1518
|
- `EncryptedJSONField` — serializes to JSON, encrypts, and stores as `text`. Supports custom `encoder` and `decoder` parameters (same as `JSONField`).
|
|
978
1519
|
|
|
979
1520
|
**Limitations:**
|
|
980
1521
|
|
|
981
|
-
- **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.
|
|
1522
|
+
- **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`.
|
|
1523
|
+
- **`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:
|
|
1524
|
+
|
|
1525
|
+
```python
|
|
1526
|
+
Integration.query.get_or_create(name="acme", defaults={"api_key": "k"})
|
|
1527
|
+
```
|
|
1528
|
+
|
|
1529
|
+
The same applies to `upsert()`, and to an expression right-hand side like `filter(api_key=F("name"))` — the column is still ciphertext.
|
|
1530
|
+
|
|
982
1531
|
- **No indexes or constraints** — encrypted fields cannot be used in indexes or unique constraints. Preflight checks will catch this.
|
|
983
|
-
- **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`
|
|
1532
|
+
- **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.
|
|
984
1533
|
|
|
985
1534
|
**Key rotation:**
|
|
986
1535
|
|
|
@@ -996,16 +1545,69 @@ Use [`ForeignKeyField`](./fields/related.py#ForeignKeyField) for many-to-one and
|
|
|
996
1545
|
|
|
997
1546
|
```python
|
|
998
1547
|
from plain import postgres
|
|
999
|
-
from plain.postgres import types
|
|
1548
|
+
from plain.postgres import Field, types
|
|
1000
1549
|
|
|
1001
1550
|
|
|
1002
1551
|
@postgres.register_model
|
|
1003
1552
|
class Book(postgres.Model):
|
|
1004
|
-
title: str = types.TextField(max_length=200)
|
|
1005
|
-
author: Author = types.ForeignKeyField(
|
|
1553
|
+
title: Field[str] = types.TextField(max_length=200)
|
|
1554
|
+
author: Field[Author] = types.ForeignKeyField(Author, on_delete=postgres.CASCADE)
|
|
1006
1555
|
tags = types.ManyToManyField("Tag")
|
|
1007
1556
|
```
|
|
1008
1557
|
|
|
1558
|
+
### Referring to a model by name
|
|
1559
|
+
|
|
1560
|
+
`ForeignKeyField` also takes a `"package.Model"` string (or `"self"`) instead of
|
|
1561
|
+
the class. That is a **runtime** device, for the cases where the class isn't
|
|
1562
|
+
importable at the point of declaration: a circular reference between two models,
|
|
1563
|
+
a self-reference, or a framework package pointing at the app's own `User`.
|
|
1564
|
+
|
|
1565
|
+
The annotation is unaffected — a string-referenced foreign key is annotated
|
|
1566
|
+
`Field[Related]` exactly like a class-referenced one, and gets the same typed
|
|
1567
|
+
surface: `Model.user.email.equals(...)` traversal, `Model.user.id.equals(...)`
|
|
1568
|
+
conditions, and a typed `Model(user=...)` constructor. The string says nothing
|
|
1569
|
+
to the checker, so the annotation is where `Related` comes from; import it under
|
|
1570
|
+
`TYPE_CHECKING` when importing it for real would be the cycle you were avoiding:
|
|
1571
|
+
|
|
1572
|
+
```python
|
|
1573
|
+
from __future__ import annotations
|
|
1574
|
+
|
|
1575
|
+
from typing import TYPE_CHECKING
|
|
1576
|
+
|
|
1577
|
+
from plain import postgres
|
|
1578
|
+
from plain.postgres import Field, types
|
|
1579
|
+
|
|
1580
|
+
if TYPE_CHECKING:
|
|
1581
|
+
from app.users.models import User
|
|
1582
|
+
|
|
1583
|
+
|
|
1584
|
+
@postgres.register_model
|
|
1585
|
+
class PinnedNavItem(postgres.Model):
|
|
1586
|
+
# Cross-package: plain.admin can't import the app's User at runtime.
|
|
1587
|
+
user: Field[User] = types.ForeignKeyField("users.User", on_delete=postgres.CASCADE)
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
@postgres.register_model
|
|
1591
|
+
class TreeNode(postgres.Model):
|
|
1592
|
+
name: Field[str] = types.TextField(max_length=100)
|
|
1593
|
+
# Self-reference, nullable: `Field[T | None]` plus `default=None`.
|
|
1594
|
+
parent: Field[TreeNode | None] = types.ForeignKeyField(
|
|
1595
|
+
"self", on_delete=postgres.CASCADE, allow_null=True, default=None
|
|
1596
|
+
)
|
|
1597
|
+
```
|
|
1598
|
+
|
|
1599
|
+
What does _not_ work is annotating the field with the related model itself
|
|
1600
|
+
(`user: User = types.ForeignKeyField("users.User", ...)`). That names a model
|
|
1601
|
+
instance rather than a field, so the checker never sees a descriptor: class
|
|
1602
|
+
access is a `User` rather than `type[User]`, `Model.user.id` is an `int`, and
|
|
1603
|
+
the condition methods are gone. `plain preflight`'s
|
|
1604
|
+
`postgres.foreign_key_annotated_as_value` finds these: it reports a foreign key
|
|
1605
|
+
whose annotation — on the model or on any of its base classes — names the
|
|
1606
|
+
related model. It asks for positive evidence, so it stays quiet when it can't
|
|
1607
|
+
establish that: an unannotated foreign key (no constructor argument at all), a
|
|
1608
|
+
`ClassVar[...]` one, and any spelling whose meaning it can't read. Treat a clean
|
|
1609
|
+
run as "nothing found", not "nothing to find".
|
|
1610
|
+
|
|
1009
1611
|
### Foreign key access
|
|
1010
1612
|
|
|
1011
1613
|
Accessing a foreign key gives you the related object without a query — only its primary key is loaded up front:
|
|
@@ -1019,6 +1621,8 @@ book.author.name # one query — loads the rest of the row
|
|
|
1019
1621
|
|
|
1020
1622
|
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.
|
|
1021
1623
|
|
|
1624
|
+
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`.
|
|
1625
|
+
|
|
1022
1626
|
The partial-instance shortcut is safe because Plain always creates a database foreign-key constraint, so the referenced row is guaranteed to exist.
|
|
1023
1627
|
|
|
1024
1628
|
### Constraints are checked immediately
|
|
@@ -1034,21 +1638,26 @@ A migration can add a column, backfill it in `RunPython`, and drop or alter colu
|
|
|
1034
1638
|
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):
|
|
1035
1639
|
|
|
1036
1640
|
```python
|
|
1641
|
+
from typing import ClassVar
|
|
1642
|
+
|
|
1037
1643
|
from plain import postgres
|
|
1038
|
-
from plain.postgres import types
|
|
1644
|
+
from plain.postgres import Field, types
|
|
1039
1645
|
|
|
1040
1646
|
|
|
1041
1647
|
@postgres.register_model
|
|
1042
1648
|
class Author(postgres.Model):
|
|
1043
|
-
name: str = types.TextField(max_length=200)
|
|
1044
|
-
# Explicit reverse accessor for all books by this author
|
|
1045
|
-
|
|
1649
|
+
name: Field[str] = types.TextField(max_length=200)
|
|
1650
|
+
# Explicit reverse accessor for all books by this author.
|
|
1651
|
+
# ClassVar keeps it out of the typed constructor (it's an accessor, not a field).
|
|
1652
|
+
books: ClassVar[types.ReverseForeignKey[Book]] = types.ReverseForeignKey(
|
|
1653
|
+
to="Book", field="author"
|
|
1654
|
+
)
|
|
1046
1655
|
|
|
1047
1656
|
|
|
1048
1657
|
@postgres.register_model
|
|
1049
1658
|
class Book(postgres.Model):
|
|
1050
|
-
title: str = types.TextField(max_length=200)
|
|
1051
|
-
author: Author = types.ForeignKeyField(Author, on_delete=postgres.CASCADE)
|
|
1659
|
+
title: Field[str] = types.TextField(max_length=200)
|
|
1660
|
+
author: Field[Author] = types.ForeignKeyField(Author, on_delete=postgres.CASCADE)
|
|
1052
1661
|
|
|
1053
1662
|
|
|
1054
1663
|
# Usage
|
|
@@ -1065,14 +1674,16 @@ For many-to-many relationships:
|
|
|
1065
1674
|
```python
|
|
1066
1675
|
@postgres.register_model
|
|
1067
1676
|
class Feature(postgres.Model):
|
|
1068
|
-
name: str = types.TextField(max_length=100)
|
|
1677
|
+
name: Field[str] = types.TextField(max_length=100)
|
|
1069
1678
|
# Explicit reverse accessor for all cars with this feature
|
|
1070
|
-
cars = types.ReverseManyToMany(
|
|
1679
|
+
cars: ClassVar[types.ReverseManyToMany[Car]] = types.ReverseManyToMany(
|
|
1680
|
+
to="Car", field="features"
|
|
1681
|
+
)
|
|
1071
1682
|
|
|
1072
1683
|
|
|
1073
1684
|
@postgres.register_model
|
|
1074
1685
|
class Car(postgres.Model):
|
|
1075
|
-
model: str = types.TextField(max_length=100)
|
|
1686
|
+
model: Field[str] = types.TextField(max_length=100)
|
|
1076
1687
|
features = types.ManyToManyField(Feature)
|
|
1077
1688
|
|
|
1078
1689
|
|
|
@@ -1091,16 +1702,16 @@ for car in feature.cars.all():
|
|
|
1091
1702
|
|
|
1092
1703
|
Reverse relations are optional — if you don't declare them, the automatic `{model}_set` accessor still works.
|
|
1093
1704
|
|
|
1094
|
-
To get type checking for custom QuerySet methods
|
|
1705
|
+
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:
|
|
1095
1706
|
|
|
1096
1707
|
```python
|
|
1097
1708
|
# Basic usage
|
|
1098
|
-
books: types.ReverseForeignKey[Book] = types.ReverseForeignKey(
|
|
1709
|
+
books: ClassVar[types.ReverseForeignKey[Book]] = types.ReverseForeignKey(
|
|
1099
1710
|
to="Book", field="author"
|
|
1100
1711
|
)
|
|
1101
1712
|
|
|
1102
1713
|
# With custom QuerySet for proper method recognition
|
|
1103
|
-
books: types.ReverseForeignKey[Book, BookQuerySet] = types.ReverseForeignKey(
|
|
1714
|
+
books: ClassVar[types.ReverseForeignKey[Book, BookQuerySet]] = types.ReverseForeignKey(
|
|
1104
1715
|
to="Book", field="author"
|
|
1105
1716
|
)
|
|
1106
1717
|
|
|
@@ -1117,8 +1728,8 @@ author.books.query.published()
|
|
|
1117
1728
|
```python
|
|
1118
1729
|
@postgres.register_model
|
|
1119
1730
|
class User(postgres.Model):
|
|
1120
|
-
email: str = types.EmailField()
|
|
1121
|
-
age: int = types.IntegerField()
|
|
1731
|
+
email: Field[str] = types.EmailField()
|
|
1732
|
+
age: Field[int] = types.IntegerField()
|
|
1122
1733
|
|
|
1123
1734
|
model_options = postgres.Options(
|
|
1124
1735
|
constraints=[
|
|
@@ -1156,7 +1767,7 @@ except (psycopg.IntegrityError, ValidationError):
|
|
|
1156
1767
|
... # lost a race — reload and retry, or report it
|
|
1157
1768
|
```
|
|
1158
1769
|
|
|
1159
|
-
For a plain insert-or-update with no per-row logic, `
|
|
1770
|
+
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.
|
|
1160
1771
|
|
|
1161
1772
|
### Indexes and constraints
|
|
1162
1773
|
|
|
@@ -1164,9 +1775,9 @@ You can optimize queries and ensure data integrity with indexes and constraints.
|
|
|
1164
1775
|
|
|
1165
1776
|
```python
|
|
1166
1777
|
class User(postgres.Model):
|
|
1167
|
-
email: str = types.EmailField()
|
|
1168
|
-
username: str = types.TextField(max_length=150)
|
|
1169
|
-
age: int = types.IntegerField()
|
|
1778
|
+
email: Field[str] = types.EmailField()
|
|
1779
|
+
username: Field[str] = types.TextField(max_length=150)
|
|
1780
|
+
age: Field[int] = types.IntegerField()
|
|
1170
1781
|
|
|
1171
1782
|
model_options = postgres.Options(
|
|
1172
1783
|
indexes=[
|
|
@@ -1241,14 +1852,14 @@ Add indexes for columns that appear in `.filter()`, `.order_by()`, or `.exclude(
|
|
|
1241
1852
|
```python
|
|
1242
1853
|
# Bad — full table scan on every filtered query
|
|
1243
1854
|
class Order(postgres.Model):
|
|
1244
|
-
status: str = types.TextField(max_length=20)
|
|
1245
|
-
created_at: datetime = types.DateTimeField()
|
|
1855
|
+
status: Field[str] = types.TextField(max_length=20)
|
|
1856
|
+
created_at: Field[datetime] = types.DateTimeField()
|
|
1246
1857
|
|
|
1247
1858
|
|
|
1248
1859
|
# Good — indexed for common queries
|
|
1249
1860
|
class Order(postgres.Model):
|
|
1250
|
-
status: str = types.TextField(max_length=20)
|
|
1251
|
-
created_at: datetime = types.DateTimeField()
|
|
1861
|
+
status: Field[str] = types.TextField(max_length=20)
|
|
1862
|
+
created_at: Field[datetime] = types.DateTimeField()
|
|
1252
1863
|
|
|
1253
1864
|
model_options = postgres.Options(
|
|
1254
1865
|
indexes=[postgres.Index(fields=["status", "-created_at"])],
|
|
@@ -1279,12 +1890,12 @@ CASCADE for owned children, RESTRICT for referenced data, SET_NULL for optional
|
|
|
1279
1890
|
|
|
1280
1891
|
```python
|
|
1281
1892
|
# Bad — blindly using CASCADE everywhere
|
|
1282
|
-
company: Company = types.ForeignKeyField(
|
|
1283
|
-
|
|
1893
|
+
company: Field[Company] = types.ForeignKeyField(
|
|
1894
|
+
Company, on_delete=postgres.CASCADE
|
|
1284
1895
|
) # deleting company deletes invoices!
|
|
1285
1896
|
|
|
1286
1897
|
# Good — block the delete while invoices reference the company
|
|
1287
|
-
company: Company = types.ForeignKeyField(
|
|
1898
|
+
company: Field[Company] = types.ForeignKeyField(Company, on_delete=postgres.RESTRICT)
|
|
1288
1899
|
```
|
|
1289
1900
|
|
|
1290
1901
|
#### No `allow_null` on string fields
|
|
@@ -1293,10 +1904,10 @@ Use `default=""` instead of `allow_null=True` to avoid two representations of "e
|
|
|
1293
1904
|
|
|
1294
1905
|
```python
|
|
1295
1906
|
# Bad — NULL and "" both mean "empty"
|
|
1296
|
-
nickname: str = types.TextField(max_length=50, allow_null=True)
|
|
1907
|
+
nickname: Field[str] = types.TextField(max_length=50, allow_null=True)
|
|
1297
1908
|
|
|
1298
1909
|
# Good — single empty representation
|
|
1299
|
-
nickname: str = types.TextField(max_length=50, default="")
|
|
1910
|
+
nickname: Field[str] = types.TextField(max_length=50, default="")
|
|
1300
1911
|
```
|
|
1301
1912
|
|
|
1302
1913
|
## Forms
|