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