plain.postgres 0.119.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.119.0 → plain_postgres-0.120.0}/PKG-INFO +138 -13
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/CHANGELOG.md +18 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/README.md +137 -12
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +38 -8
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/base.py +103 -11
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/encrypted.py +17 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/related_descriptors.py +1 -1
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/reverse_descriptors.py +1 -1
- plain_postgres-0.120.0/plain/postgres/preflight/models.py +704 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/types.pyi +18 -12
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/pyproject.toml +1 -1
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/delete.py +1 -1
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/trees.py +1 -1
- 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.119.0 → plain_postgres-0.120.0}/tests/internal/test_stub_runtime_conformance.py +4 -2
- plain_postgres-0.120.0/tests/internal/test_typed_where_internals.py +204 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_unresolved_relation_refs.py +5 -1
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_typed_where.py +12 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/conditions_encrypted.py +12 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/conditions_value_types.py +45 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/construction_required_fields.py +20 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/construction_unknown_kwargs.py +19 -0
- plain_postgres-0.120.0/tests/typing/relations_string_reference.py +135 -0
- plain_postgres-0.119.0/plain/postgres/preflight/models.py +0 -320
- plain_postgres-0.119.0/tests/internal/test_typed_where_internals.py +0 -101
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/.gitignore +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/CLAUDE.md +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/LICENSE +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/README.md +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/__init__.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/adapters.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/base.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/cli/decorators.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/cli/sync.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/connection.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/constraints.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/convergence/analysis.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/convergence/corrections.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/convergence/planning.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/databases.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/expressions.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/binary.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/related.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/related_typed.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/temporal.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/fields/uuid.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/__init__.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/context.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/helpers.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/ownership.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/runner.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/health/types.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/introspection/schema.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/middleware.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/baselines.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/reset.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/preflight/__init__.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/preflight/database.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/preflight/indexes.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/query.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/schema_lock.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/selectable.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/sources.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/sql/compiler.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/sql/query.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/test/database.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.119.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.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0019_shadowtarget_shadowsource.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0020_stringconditionsexample.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0021_returningevent.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0022_upsertitem.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0023_upsertpair.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0024_upserttenant_upsertvaluekey_upsertscoped.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0025_upsertfloatkey.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0026_upsertdecimalkey.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0027_upsertstamped.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/0028_aliascollisionexample.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/__init__.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/alias_collisions.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/constraints.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/defaults.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/encrypted.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/forms.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/indexes.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/iteration.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/mixins.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/nullability.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/querysets.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/relationships.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/returning.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/shadowing.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/storage_parameters.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/string_conditions.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/models/upsert.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/conftest.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/conftest.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_apply_replan.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_autodetector_not_null_errors.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_baselines.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_conflict_lock_order.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_conflict_sort_value.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_connection_isolation.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_connection_pool.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_connection_self_heal.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_constraint_violation_error.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_convergence.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_constraints.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_defaults.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_fk.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_indexes.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_nullability.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_convergence_timeouts.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_databases_not_on_runtime_path.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_db_expression_defaults.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_diagnose.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_encrypted_internals.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_executor_connection_hook.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_fk_characterization.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_health.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_introspection.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_literal_default_persistence.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_lock_mode_sql.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_m2m_value_from_object.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_management_connection.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_meta_related_objects.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_migration_executor.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_migrations_reset.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_otel_metrics.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_random_string_sql.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_replaces_removed.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_returning_internals.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_returning_order.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_rollback_exc_attribution.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_schema_lock.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_schema_normalize_type.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_schema_timeouts.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_shipped_baselines.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_typed_construction_preflight.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/internal/test_writer_operation_options.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_bulk_upsert.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_create_update.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_database_url.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_databases.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_deferred_loading.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_delete_behaviors.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_encrypted_fields.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_exceptions.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_field_defaults.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_functions_uuid.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_integrity_error_mapping.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_iterator.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_m2m.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_manager_assignment.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_manual_pk.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_mixins.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_modelform_roundtrip.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_only_empty_defaults.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_order_by_expressions.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_queryset_ordered.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_queryset_repr.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_queryset_slicing.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_random_string_field.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_raw_query.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_read_only_transactions.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_related.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_related_instance_filter.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_returning.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_row_locking.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_select.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_typed_where_fk.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/public/test_upsert.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/README.md +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/bulk_writes.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/construction_mixins.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/construction_value_types.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/field_access.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/field_constructors.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/queryset_access.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/relations_foreign_key.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/relations_reverse.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/returning_writes.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/select_ladder.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/select_rows.py +0 -0
- {plain_postgres-0.119.0 → plain_postgres-0.120.0}/tests/typing/upsert_writes.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
|
|
@@ -61,12 +61,24 @@ class User(postgres.Model):
|
|
|
61
61
|
|
|
62
62
|
Annotate each field with `Field[T]` (the value type) — that's what gives the
|
|
63
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
|
-
not
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
70
82
|
|
|
71
83
|
Field types declared outside `plain.postgres.types` — `PasswordField`, or one of
|
|
72
84
|
your own — are the exception: their stub still types the value, but they are
|
|
@@ -217,6 +229,8 @@ For more advanced querying options, see the [`QuerySet`](./query.py#QuerySet) cl
|
|
|
217
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:
|
|
218
230
|
|
|
219
231
|
```python
|
|
232
|
+
from datetime import datetime
|
|
233
|
+
|
|
220
234
|
from plain import postgres
|
|
221
235
|
from plain.postgres import Field, types
|
|
222
236
|
|
|
@@ -226,6 +240,8 @@ class User(postgres.Model):
|
|
|
226
240
|
email: Field[str] = types.EmailField()
|
|
227
241
|
role: Field[str] = types.TextField(max_length=20)
|
|
228
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)
|
|
229
245
|
|
|
230
246
|
|
|
231
247
|
# Each argument is a condition; multiple arguments are ANDed together.
|
|
@@ -235,7 +251,7 @@ admins = User.query.where(
|
|
|
235
251
|
)
|
|
236
252
|
```
|
|
237
253
|
|
|
238
|
-
Every field exposes `equals`, `not_equal`, `gt`, `gte`, `lt`, `lte`, `is_null`, and `is_in`. Text fields add `contains`, `icontains`, `
|
|
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 `~`:
|
|
239
255
|
|
|
240
256
|
```python
|
|
241
257
|
# Membership, negation, and OR
|
|
@@ -244,6 +260,47 @@ User.query.where(~User.role.equals("guest"))
|
|
|
244
260
|
User.query.where(User.email.endswith("@example.com") | User.role.equals("admin"))
|
|
245
261
|
```
|
|
246
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
|
+
|
|
247
304
|
Conditions traverse foreign keys — accessing a field through a relation builds the joined lookup:
|
|
248
305
|
|
|
249
306
|
```python
|
|
@@ -267,9 +324,24 @@ A class-level many-to-many (`Widget.tags`) is _not_ an entry point: it has no tr
|
|
|
267
324
|
|
|
268
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.
|
|
269
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
|
+
|
|
270
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.
|
|
271
343
|
|
|
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`.
|
|
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.
|
|
273
345
|
|
|
274
346
|
### Selecting columns with select()
|
|
275
347
|
|
|
@@ -1479,10 +1551,63 @@ from plain.postgres import Field, types
|
|
|
1479
1551
|
@postgres.register_model
|
|
1480
1552
|
class Book(postgres.Model):
|
|
1481
1553
|
title: Field[str] = types.TextField(max_length=200)
|
|
1482
|
-
author: Author = types.ForeignKeyField(
|
|
1554
|
+
author: Field[Author] = types.ForeignKeyField(Author, on_delete=postgres.CASCADE)
|
|
1483
1555
|
tags = types.ManyToManyField("Tag")
|
|
1484
1556
|
```
|
|
1485
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
|
+
|
|
1486
1611
|
### Foreign key access
|
|
1487
1612
|
|
|
1488
1613
|
Accessing a foreign key gives you the related object without a query — only its primary key is loaded up front:
|
|
@@ -1765,12 +1890,12 @@ CASCADE for owned children, RESTRICT for referenced data, SET_NULL for optional
|
|
|
1765
1890
|
|
|
1766
1891
|
```python
|
|
1767
1892
|
# Bad — blindly using CASCADE everywhere
|
|
1768
|
-
company: Company = types.ForeignKeyField(
|
|
1769
|
-
|
|
1893
|
+
company: Field[Company] = types.ForeignKeyField(
|
|
1894
|
+
Company, on_delete=postgres.CASCADE
|
|
1770
1895
|
) # deleting company deletes invoices!
|
|
1771
1896
|
|
|
1772
1897
|
# Good — block the delete while invoices reference the company
|
|
1773
|
-
company: Company = types.ForeignKeyField(
|
|
1898
|
+
company: Field[Company] = types.ForeignKeyField(Company, on_delete=postgres.RESTRICT)
|
|
1774
1899
|
```
|
|
1775
1900
|
|
|
1776
1901
|
#### No `allow_null` on string fields
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
+
## [0.120.0](https://github.com/dropseed/plain/releases/plain-postgres@0.120.0) (2026-09-21)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- String-referenced foreign keys get the typed condition surface. `ForeignKeyField("users.User", ...)` now returns the same typed descriptor as the class-referenced form, so the model annotates it `user: Field[User]` (with a `TYPE_CHECKING` import where the runtime import would cycle) and `Model.user.email.equals(...)`, `Model.user.id.is_in(...)` and typed construction all work through it. The rule is now one rule for every foreign key: annotate `Field[Related]` (`Field[Related | None]` plus `default=None` when nullable, `Field[Foo | None]` for `"self"`); the string form is a runtime device for import cycles and cross-package references only. New preflight warning `postgres.foreign_key_annotated_as_value` reports a foreign key whose annotation names the related model instead of `Field[...]`, with the exact rewrite. It exists because the type checker is silent on the old non-nullable spelling: PEP 681 only compares a field's declared type against the annotation when `default=` is passed ([b072153088](https://github.com/dropseed/plain/commit/b072153088))
|
|
8
|
+
- `iequals`, `istartswith` and `iendswith` on `Field[str]`, the case-insensitive counterparts of `equals`/`startswith`/`endswith` (the `iexact`/`istartswith`/`iendswith` lookups). Blocked on encrypted fields like the other string conditions ([26c35167d8](https://github.com/dropseed/plain/commit/26c35167d8))
|
|
9
|
+
- Comparisons against another column: `equals`, `not_equal`, `gt`, `gte`, `lt` and `lte` accept a field of the same value type, so `Post.query.where(Post.updated_at.gt(Post.created_at))` replaces `filter(updated_at__gt=F("created_at"))` and type-checks. A non-null column accepts a nullable one on the right; the reverse can't be expressed. Traversed fields carry their relation prefix; a column from another model trips the cross-model guard; an encrypted column is refused on either side. `F()` and other expressions are still accepted as the untyped escape hatch ([26c35167d8](https://github.com/dropseed/plain/commit/26c35167d8))
|
|
10
|
+
- New preflight warning `postgres.nullable_field_without_default`: an `allow_null=True` field with no declared default is optional in the constructor at runtime but required to a type checker; adding `default=None` makes them agree and changes no schema. `Field.has_declared_default()` reports a declared `default=None` on the fields that accept only that default ([55d2a71790](https://github.com/dropseed/plain/commit/55d2a71790))
|
|
11
|
+
- Docs corrected and filled from the internal conversion pass: typed construction is opt-in at runtime but not for a type-checked app (an unannotated model's constructor calls are unknown-argument errors, so every model must be annotated); `is_null(False)` is the conversion for `__isnull=False` (`~x.is_null()` compiles to `NOT (x IS NULL)`); typed conditions are a `Q` replacement, accepted by `When()` and an aggregate's `filter=` unchanged; `where()` preserves written condition order where `filter()` sorted kwargs alphabetically, so a converted multi-condition filter can change the emitted clause order; value-type strictness is a type-checker guard and runtime coercion is unchanged ([55d2a71790](https://github.com/dropseed/plain/commit/55d2a71790)) ([10236185ea](https://github.com/dropseed/plain/commit/10236185ea))
|
|
12
|
+
|
|
13
|
+
### Upgrade instructions
|
|
14
|
+
|
|
15
|
+
- Rewrite every string-referenced foreign key annotation from `X: Related = types.ForeignKeyField("...")` to `X: Field[Related] = types.ForeignKeyField("...")`, nullable ones to `Field[Related | None]` with `default=None`. Import `Related` under `if TYPE_CHECKING:` where the runtime import would cycle. This corrects the 0.119.0 instruction to annotate a string forward reference with the bare model type. `plain preflight` lists the fields to change.
|
|
16
|
+
- If the project runs a type checker, annotate every model; 0.119.0 called annotation optional, which is true only at runtime.
|
|
17
|
+
- Add `default=None` to nullable fields that lack a declared default; `plain preflight` lists them. No behavior or schema change.
|
|
18
|
+
- When converting a multi-kwarg `filter()` to `where()`, list the conditions in the order `filter()` sorted them (alphabetical by field name) if anything pins the generated SQL.
|
|
19
|
+
- `filter()`, `exclude()`, `values()`, `values_list()`, `only()`, `defer()` and string `annotate()` are unchanged.
|
|
20
|
+
|
|
3
21
|
## [0.119.0](https://github.com/dropseed/plain/releases/plain-postgres@0.119.0) (2026-09-20)
|
|
4
22
|
|
|
5
23
|
### What's changed
|
|
@@ -48,12 +48,24 @@ class User(postgres.Model):
|
|
|
48
48
|
|
|
49
49
|
Annotate each field with `Field[T]` (the value type) — that's what gives the
|
|
50
50
|
model a type-checked constructor: `User(email="a@b.com")` flags wrong value
|
|
51
|
-
types, unknown field names, and missing required fields.
|
|
52
|
-
|
|
53
|
-
not
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
types, unknown field names, and missing required fields.
|
|
52
|
+
|
|
53
|
+
Annotating is not per-model opt-in. `postgres.Model` carries the transform, so
|
|
54
|
+
the checker synthesizes every subclass's constructor from its annotated
|
|
55
|
+
attributes and nothing else. Drop the annotation from `email` above and it stops
|
|
56
|
+
being a constructor argument at all — `User(email="a@b.com")` is then rejected as
|
|
57
|
+
an unknown argument. The runtime doesn't care either way, but if you run a type
|
|
58
|
+
checker, every model has to be annotated.
|
|
59
|
+
|
|
60
|
+
A field is optional in that constructor only when its definition passes
|
|
61
|
+
`default=` (this is general, not nullable-specific — a `required=False` field
|
|
62
|
+
with no `default=` is still a required constructor arg), so nullable fields use
|
|
63
|
+
`Field[T | None]` with `default=None`. The runtime already treats a nullable
|
|
64
|
+
field as optional — constructing without it yields `None` — so `default=None`
|
|
65
|
+
exists for the checker; it persists nothing and changes no schema.
|
|
66
|
+
`plain preflight` lists the nullable fields still missing one
|
|
67
|
+
(`postgres.nullable_field_without_default`). DB-owned fields (`id`,
|
|
68
|
+
`create_now`, generated values) are auto-excluded from the constructor.
|
|
57
69
|
|
|
58
70
|
Field types declared outside `plain.postgres.types` — `PasswordField`, or one of
|
|
59
71
|
your own — are the exception: their stub still types the value, but they are
|
|
@@ -204,6 +216,8 @@ For more advanced querying options, see the [`QuerySet`](./query.py#QuerySet) cl
|
|
|
204
216
|
`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:
|
|
205
217
|
|
|
206
218
|
```python
|
|
219
|
+
from datetime import datetime
|
|
220
|
+
|
|
207
221
|
from plain import postgres
|
|
208
222
|
from plain.postgres import Field, types
|
|
209
223
|
|
|
@@ -213,6 +227,8 @@ class User(postgres.Model):
|
|
|
213
227
|
email: Field[str] = types.EmailField()
|
|
214
228
|
role: Field[str] = types.TextField(max_length=20)
|
|
215
229
|
age: Field[int | None] = types.IntegerField(allow_null=True, default=None)
|
|
230
|
+
created_at: Field[datetime] = types.DateTimeField(create_now=True)
|
|
231
|
+
updated_at: Field[datetime] = types.DateTimeField(update_now=True)
|
|
216
232
|
|
|
217
233
|
|
|
218
234
|
# Each argument is a condition; multiple arguments are ANDed together.
|
|
@@ -222,7 +238,7 @@ admins = User.query.where(
|
|
|
222
238
|
)
|
|
223
239
|
```
|
|
224
240
|
|
|
225
|
-
Every field exposes `equals`, `not_equal`, `gt`, `gte`, `lt`, `lte`, `is_null`, and `is_in`. Text fields add `contains`, `icontains`, `
|
|
241
|
+
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 `~`:
|
|
226
242
|
|
|
227
243
|
```python
|
|
228
244
|
# Membership, negation, and OR
|
|
@@ -231,6 +247,47 @@ User.query.where(~User.role.equals("guest"))
|
|
|
231
247
|
User.query.where(User.email.endswith("@example.com") | User.role.equals("admin"))
|
|
232
248
|
```
|
|
233
249
|
|
|
250
|
+
`~` 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:
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
User.query.where(~User.age.is_null()) # WHERE NOT ("age" IS NULL)
|
|
254
|
+
User.query.where(User.age.is_null(False)) # WHERE "age" IS NOT NULL
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
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.
|
|
258
|
+
|
|
259
|
+
A comparison can also take another column of the same value type, instead of a value:
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
# Q(updated_at__gt=F("created_at"))
|
|
263
|
+
User.query.where(User.updated_at.gt(User.created_at))
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`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.
|
|
267
|
+
|
|
268
|
+
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"))`.
|
|
269
|
+
|
|
270
|
+
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()`.
|
|
271
|
+
|
|
272
|
+
**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=`:
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
from plain.postgres.aggregates import Count
|
|
276
|
+
from plain.postgres.expressions import Case, Value, When
|
|
277
|
+
|
|
278
|
+
User.query.annotate(
|
|
279
|
+
tier=Case(When(User.age.gte(18), then=Value("adult")), default=Value("minor"))
|
|
280
|
+
)
|
|
281
|
+
User.query.values("role").annotate(adults=Count("id", filter=User.age.gte(18)))
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
```sql
|
|
285
|
+
-- the Case annotation
|
|
286
|
+
CASE WHEN "age" >= %s THEN %s ELSE %s END AS "tier"
|
|
287
|
+
-- the Count annotation
|
|
288
|
+
COUNT("id") FILTER (WHERE "age" >= %s) AS "adults"
|
|
289
|
+
```
|
|
290
|
+
|
|
234
291
|
Conditions traverse foreign keys — accessing a field through a relation builds the joined lookup:
|
|
235
292
|
|
|
236
293
|
```python
|
|
@@ -254,9 +311,24 @@ A class-level many-to-many (`Widget.tags`) is _not_ an entry point: it has no tr
|
|
|
254
311
|
|
|
255
312
|
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.
|
|
256
313
|
|
|
314
|
+
**`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:
|
|
315
|
+
|
|
316
|
+
```sql
|
|
317
|
+
-- filter(role="admin", email="a@example.com")
|
|
318
|
+
WHERE ("email" = %s AND "role" = %s) -- params: ('a@example.com', 'admin')
|
|
319
|
+
-- where(User.role.equals("admin"), User.email.equals("a@example.com"))
|
|
320
|
+
WHERE ("role" = %s AND "email" = %s) -- params: ('admin', 'a@example.com')
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
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.
|
|
324
|
+
|
|
325
|
+
**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.
|
|
326
|
+
|
|
327
|
+
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.
|
|
328
|
+
|
|
257
329
|
**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.
|
|
258
330
|
|
|
259
|
-
[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`.
|
|
331
|
+
[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.
|
|
260
332
|
|
|
261
333
|
### Selecting columns with select()
|
|
262
334
|
|
|
@@ -1466,10 +1538,63 @@ from plain.postgres import Field, types
|
|
|
1466
1538
|
@postgres.register_model
|
|
1467
1539
|
class Book(postgres.Model):
|
|
1468
1540
|
title: Field[str] = types.TextField(max_length=200)
|
|
1469
|
-
author: Author = types.ForeignKeyField(
|
|
1541
|
+
author: Field[Author] = types.ForeignKeyField(Author, on_delete=postgres.CASCADE)
|
|
1470
1542
|
tags = types.ManyToManyField("Tag")
|
|
1471
1543
|
```
|
|
1472
1544
|
|
|
1545
|
+
### Referring to a model by name
|
|
1546
|
+
|
|
1547
|
+
`ForeignKeyField` also takes a `"package.Model"` string (or `"self"`) instead of
|
|
1548
|
+
the class. That is a **runtime** device, for the cases where the class isn't
|
|
1549
|
+
importable at the point of declaration: a circular reference between two models,
|
|
1550
|
+
a self-reference, or a framework package pointing at the app's own `User`.
|
|
1551
|
+
|
|
1552
|
+
The annotation is unaffected — a string-referenced foreign key is annotated
|
|
1553
|
+
`Field[Related]` exactly like a class-referenced one, and gets the same typed
|
|
1554
|
+
surface: `Model.user.email.equals(...)` traversal, `Model.user.id.equals(...)`
|
|
1555
|
+
conditions, and a typed `Model(user=...)` constructor. The string says nothing
|
|
1556
|
+
to the checker, so the annotation is where `Related` comes from; import it under
|
|
1557
|
+
`TYPE_CHECKING` when importing it for real would be the cycle you were avoiding:
|
|
1558
|
+
|
|
1559
|
+
```python
|
|
1560
|
+
from __future__ import annotations
|
|
1561
|
+
|
|
1562
|
+
from typing import TYPE_CHECKING
|
|
1563
|
+
|
|
1564
|
+
from plain import postgres
|
|
1565
|
+
from plain.postgres import Field, types
|
|
1566
|
+
|
|
1567
|
+
if TYPE_CHECKING:
|
|
1568
|
+
from app.users.models import User
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
@postgres.register_model
|
|
1572
|
+
class PinnedNavItem(postgres.Model):
|
|
1573
|
+
# Cross-package: plain.admin can't import the app's User at runtime.
|
|
1574
|
+
user: Field[User] = types.ForeignKeyField("users.User", on_delete=postgres.CASCADE)
|
|
1575
|
+
|
|
1576
|
+
|
|
1577
|
+
@postgres.register_model
|
|
1578
|
+
class TreeNode(postgres.Model):
|
|
1579
|
+
name: Field[str] = types.TextField(max_length=100)
|
|
1580
|
+
# Self-reference, nullable: `Field[T | None]` plus `default=None`.
|
|
1581
|
+
parent: Field[TreeNode | None] = types.ForeignKeyField(
|
|
1582
|
+
"self", on_delete=postgres.CASCADE, allow_null=True, default=None
|
|
1583
|
+
)
|
|
1584
|
+
```
|
|
1585
|
+
|
|
1586
|
+
What does _not_ work is annotating the field with the related model itself
|
|
1587
|
+
(`user: User = types.ForeignKeyField("users.User", ...)`). That names a model
|
|
1588
|
+
instance rather than a field, so the checker never sees a descriptor: class
|
|
1589
|
+
access is a `User` rather than `type[User]`, `Model.user.id` is an `int`, and
|
|
1590
|
+
the condition methods are gone. `plain preflight`'s
|
|
1591
|
+
`postgres.foreign_key_annotated_as_value` finds these: it reports a foreign key
|
|
1592
|
+
whose annotation — on the model or on any of its base classes — names the
|
|
1593
|
+
related model. It asks for positive evidence, so it stays quiet when it can't
|
|
1594
|
+
establish that: an unannotated foreign key (no constructor argument at all), a
|
|
1595
|
+
`ClassVar[...]` one, and any spelling whose meaning it can't read. Treat a clean
|
|
1596
|
+
run as "nothing found", not "nothing to find".
|
|
1597
|
+
|
|
1473
1598
|
### Foreign key access
|
|
1474
1599
|
|
|
1475
1600
|
Accessing a foreign key gives you the related object without a query — only its primary key is loaded up front:
|
|
@@ -1752,12 +1877,12 @@ CASCADE for owned children, RESTRICT for referenced data, SET_NULL for optional
|
|
|
1752
1877
|
|
|
1753
1878
|
```python
|
|
1754
1879
|
# Bad — blindly using CASCADE everywhere
|
|
1755
|
-
company: Company = types.ForeignKeyField(
|
|
1756
|
-
|
|
1880
|
+
company: Field[Company] = types.ForeignKeyField(
|
|
1881
|
+
Company, on_delete=postgres.CASCADE
|
|
1757
1882
|
) # deleting company deletes invoices!
|
|
1758
1883
|
|
|
1759
1884
|
# Good — block the delete while invoices reference the company
|
|
1760
|
-
company: Company = types.ForeignKeyField(
|
|
1885
|
+
company: Field[Company] = types.ForeignKeyField(Company, on_delete=postgres.RESTRICT)
|
|
1761
1886
|
```
|
|
1762
1887
|
|
|
1763
1888
|
#### No `allow_null` on string fields
|
|
@@ -30,6 +30,11 @@ class Article(postgres.Model):
|
|
|
30
30
|
created_at: Field[datetime] = types.DateTimeField(create_now=True)
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
- **Every model, not just some.** `postgres.Model` carries the transform, so the
|
|
34
|
+
checker builds each subclass's constructor out of its annotated attributes and
|
|
35
|
+
nothing else. An unannotated `name = types.TextField()` isn't a constructor
|
|
36
|
+
argument at all, and `Model(name="x")` is rejected as an unknown argument. The
|
|
37
|
+
runtime is unaffected — but a type-checked app has to annotate every model.
|
|
33
38
|
- **Value type**: `Field[str]`, `Field[int]`, `Field[datetime]`; for an FK to a
|
|
34
39
|
model class, `Field[RelatedModel]`.
|
|
35
40
|
- **Optional in the constructor = a call-site `default=`.** A stock type checker
|
|
@@ -38,17 +43,38 @@ class Article(postgres.Model):
|
|
|
38
43
|
`required=False` field with no `default=` is still a _required_ constructor arg.
|
|
39
44
|
Add `default=` to any field you intend to omit when constructing.
|
|
40
45
|
- **Nullable** (`allow_null=True`) → `Field[T | None]`, and add `default=None` so
|
|
41
|
-
it's optional in the constructor (per the rule above).
|
|
42
|
-
|
|
46
|
+
it's optional in the constructor (per the rule above). The runtime already
|
|
47
|
+
treats it as optional — constructing without it yields `None` — so
|
|
48
|
+
`default=None` is purely for the checker: it persists nothing and changes no
|
|
49
|
+
schema. `plain preflight` lists the ones still missing it
|
|
50
|
+
(`postgres.nullable_field_without_default`). Applies to class-ref and
|
|
51
|
+
string-ref FKs alike.
|
|
43
52
|
- **DB-owned** fields are still annotated but auto-excluded from the
|
|
44
53
|
constructor: the `id`, `create_now`/`update_now` datetimes, `generate=True`,
|
|
45
54
|
and `RandomStringField`.
|
|
46
|
-
- **String
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
- **String model references** (`"users.User"`, `"OtherModel"`, `"self"`) annotate
|
|
56
|
+
`Field[Related]` like any other FK — the string is a runtime device for import
|
|
57
|
+
cycles, self-references, and cross-package references (a framework package
|
|
58
|
+
pointing at the app's `User`), not a typing compromise. `Related` comes from
|
|
59
|
+
the annotation, so import it under `TYPE_CHECKING` when importing it for real
|
|
60
|
+
would be the cycle you were avoiding:
|
|
61
|
+
```python
|
|
62
|
+
if TYPE_CHECKING:
|
|
63
|
+
from app.users.models import User
|
|
64
|
+
|
|
65
|
+
user: Field[User] = types.ForeignKeyField("users.User", on_delete=postgres.CASCADE)
|
|
66
|
+
parent: Field[Foo | None] = types.ForeignKeyField(
|
|
67
|
+
"self", on_delete=postgres.CASCADE, allow_null=True, default=None
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
Annotating the field with the related model itself (`user: User = ...`) names a
|
|
71
|
+
model instance rather than a field: class access becomes a `User`,
|
|
72
|
+
`Model.user.id` is an `int`, and the condition methods disappear.
|
|
73
|
+
`plain preflight`'s `postgres.foreign_key_annotated_as_value` reports a
|
|
74
|
+
foreign key whose annotation — on the model or any base class — names the
|
|
75
|
+
related model. It wants positive evidence, so an unannotated field, a
|
|
76
|
+
`ClassVar[...]` one, or a spelling it can't read stays quiet: a clean run
|
|
77
|
+
means "nothing found", not "nothing to find".
|
|
52
78
|
- **Encrypted fields** are annotated `EncryptedField[T]` (imported from
|
|
53
79
|
`plain.postgres` alongside `Field`), not `Field[T]`. It's a `Field[T]`
|
|
54
80
|
subclass, so the constructor is typed identically, but it also carries the
|
|
@@ -103,6 +129,10 @@ Run `uv run plain docs postgres` for full workflow details.
|
|
|
103
129
|
Use `Model.query` to build querysets (e.g., `User.query.filter(is_active=True)`).
|
|
104
130
|
|
|
105
131
|
- `where()` takes typed conditions built off fields (`User.query.where(User.role.equals("admin"))`) instead of `filter()`'s string kwargs, so a typo or wrong value type is caught at the call site.
|
|
132
|
+
- `__isnull=False` converts to `is_null(False)` (`"x" IS NOT NULL`), not `~...is_null()` (`NOT ("x" IS NULL)`) — same rows, different SQL.
|
|
133
|
+
- A condition **is** a `Q`, so it goes anywhere a `Q` goes — `When(...)` (including the ones inside a `Case(...)`), `Count("id", filter=...)` — not just `where()`. `Case(...)` itself takes only `When` objects, never a bare condition.
|
|
134
|
+
- `where()` keeps conditions in the order written; `filter()` sorted its kwargs alphabetically. A converted multi-condition `filter()` can emit different WHERE text and parameter order (same rows; already-alphabetical kwargs convert unchanged) — matters for tests pinning SQL and for `pg_stat_statements`, which groups by predicate structure, not literal text.
|
|
135
|
+
- Conditions take the field's value type where string kwargs coerced: `Field[UUID].equals("...")` and `Field[int].equals("1")` are type errors. That is a type-checker guard only — runtime still coerces. Parse a CLI/URL/session string first (`uuid.UUID(raw)`, `int(raw)`); an invalid value raises `ValueError` there, before the ORM is involved.
|
|
106
136
|
- **Conditions on a relation go through its key**: `Post.query.where(Post.author.id.equals(author.id))`, `.id.is_in([...])`, `.id.is_null()` — the typed spelling of `filter(author=author)`, same SQL. `Post.author.equals(author)` raises `AttributeError`: `Post.author` is `type[Author]` to the checker (which is what makes `Post.author.email.equals(...)` work), so it offers the related model's fields, not conditions. Traversal only _starts_ from a forward FK; a many-to-many is traversable as a later hop (`WidgetTag.widget.tags.name`), but `Widget.tags` and reverse accessors are not entry points — use `filter(tags__name=...)` there.
|
|
107
137
|
- Encrypted fields can't be looked up at all: `get_or_create(secret=...)` raises — put the value in `defaults=`.
|
|
108
138
|
- Use `select_related()` for FK access in loops, `prefetch_related()` for reverse/M2N
|