plain.postgres 0.96.0__tar.gz → 0.98.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.96.0/plain/postgres/README.md → plain_postgres-0.98.0/PKG-INFO +115 -50
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/CHANGELOG.md +78 -0
- plain_postgres-0.96.0/PKG-INFO → plain_postgres-0.98.0/plain/postgres/README.md +101 -62
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/__init__.py +5 -1
- plain_postgres-0.98.0/plain/postgres/adapters.py +41 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/cli/converge.py +2 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/cli/core.py +19 -7
- plain_postgres-0.98.0/plain/postgres/cli/decorators.py +24 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/cli/diagnose.py +2 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/cli/migrations.py +6 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/cli/schema.py +2 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/cli/sync.py +2 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/config.py +4 -0
- plain_postgres-0.98.0/plain/postgres/connection.py +719 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/database_url.py +58 -9
- plain_postgres-0.98.0/plain/postgres/db.py +158 -0
- plain_postgres-0.98.0/plain/postgres/default_settings.py +42 -0
- plain_postgres-0.98.0/plain/postgres/middleware.py +37 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/otel.py +192 -11
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/preflight.py +31 -1
- plain_postgres-0.98.0/plain/postgres/sources.py +221 -0
- plain_postgres-0.98.0/plain/postgres/test/database.py +173 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/test/pytest.py +37 -30
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/transaction.py +8 -12
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/utils.py +24 -29
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/pyproject.toml +3 -3
- plain_postgres-0.98.0/tests/conftest.py +11 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_connection_isolation.py +16 -16
- plain_postgres-0.98.0/tests/test_connection_lifecycle.py +383 -0
- plain_postgres-0.98.0/tests/test_connection_pool.py +162 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_convergence_timeouts.py +2 -1
- plain_postgres-0.98.0/tests/test_executor_connection_hook.py +103 -0
- plain_postgres-0.98.0/tests/test_management_connection.py +105 -0
- plain_postgres-0.98.0/tests/test_otel_metrics.py +243 -0
- plain_postgres-0.98.0/tests/test_read_only_transactions.py +87 -0
- plain_postgres-0.96.0/plain/postgres/connection.py +0 -1330
- plain_postgres-0.96.0/plain/postgres/connections.py +0 -98
- plain_postgres-0.96.0/plain/postgres/db.py +0 -37
- plain_postgres-0.96.0/plain/postgres/default_settings.py +0 -56
- plain_postgres-0.96.0/plain/postgres/test/utils.py +0 -18
- plain_postgres-0.96.0/tests/test_connection_lifecycle.py +0 -347
- plain_postgres-0.96.0/tests/test_read_only_transactions.py +0 -116
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/.gitignore +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/CLAUDE.md +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/LICENSE +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/README.md +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/base.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/constraints.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/convergence/analysis.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/convergence/fixes.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/convergence/planning.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/expressions.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/base.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/binary.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/related.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/temporal.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/uuid.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/introspection/health.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/introspection/schema.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/query.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/sql/compiler.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/sql/query.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/types.pyi +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/__init__.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/constraints.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/defaults.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/delete.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/encrypted.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/forms.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/indexes.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/iteration.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/mixins.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/nullability.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/querysets.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/relationships.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/trees.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_autodetector_not_null_errors.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_convergence.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_convergence_constraints.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_convergence_defaults.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_convergence_fk.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_convergence_indexes.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_convergence_nullability.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_database_url.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_db_expression_defaults.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_delete_behaviors.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_encrypted_fields.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_exceptions.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_field_defaults.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_functions_uuid.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_introspection.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_iterator.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_literal_default_persistence.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_m2m.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_manager_assignment.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_migration_executor.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_mixins.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_modelform_roundtrip.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_random_string_field.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_raw_query.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_related.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_schema_normalize_type.py +0 -0
- {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_schema_timeouts.py +0 -0
|
@@ -1,9 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: plain.postgres
|
|
3
|
+
Version: 0.98.0
|
|
4
|
+
Summary: Model your data and store it in a database.
|
|
5
|
+
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Requires-Dist: plain<1.0.0,>=0.134.0
|
|
10
|
+
Requires-Dist: psycopg-pool>=3.2
|
|
11
|
+
Requires-Dist: psycopg>=3.2
|
|
12
|
+
Requires-Dist: sqlparse>=0.3.1
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
1
15
|
# plain.postgres
|
|
2
16
|
|
|
3
17
|
**Model your data and store it in a database.**
|
|
4
18
|
|
|
5
19
|
- [Overview](#overview)
|
|
6
20
|
- [Database connection](#database-connection)
|
|
21
|
+
- [Middleware](#middleware)
|
|
22
|
+
- [Bypassing a connection pooler for management operations](#bypassing-a-connection-pooler-for-management-operations)
|
|
7
23
|
- [Querying](#querying)
|
|
8
24
|
- [Schema management](#schema-management)
|
|
9
25
|
- [Syncing](#syncing)
|
|
@@ -70,35 +86,82 @@ admin_users = User.query.filter(is_admin=True)
|
|
|
70
86
|
|
|
71
87
|
## Database connection
|
|
72
88
|
|
|
73
|
-
|
|
89
|
+
Configure the database with a single URL. The canonical Plain setting is `POSTGRES_URL`:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
# app/settings.py
|
|
93
|
+
POSTGRES_URL = "postgresql://user:password@localhost:5432/dbname"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Or via environment variable:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
PLAIN_POSTGRES_URL=postgresql://user:password@localhost:5432/dbname
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Plain also reads the `DATABASE_URL` environment variable as a fallback — it's the widely-used convention for Postgres connection strings, so most hosting setups work without extra configuration:
|
|
74
103
|
|
|
75
104
|
```sh
|
|
76
105
|
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
|
77
106
|
```
|
|
78
107
|
|
|
79
|
-
|
|
108
|
+
Precedence (highest to lowest): `PLAIN_POSTGRES_URL` → `POSTGRES_URL` in `settings.py` → `DATABASE_URL` environment variable.
|
|
109
|
+
|
|
110
|
+
The URL supports any libpq connection parameter as a query string — for example `?sslmode=require&application_name=web&connect_timeout=10`. These are parsed and passed through to the driver.
|
|
111
|
+
|
|
112
|
+
To explicitly disable the database (e.g. during Docker builds where no database is available), set the URL to the string `none`:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
PLAIN_POSTGRES_URL=none
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Middleware
|
|
119
|
+
|
|
120
|
+
Connections are checked out lazily on first use and returned to the pool when the HTTP request finishes. That's handled by [`DatabaseConnectionMiddleware`](./middleware.py#DatabaseConnectionMiddleware) — add it to `MIDDLEWARE` once you install `plain.postgres`:
|
|
80
121
|
|
|
81
122
|
```python
|
|
82
123
|
# app/settings.py
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
POSTGRES_PASSWORD = "password"
|
|
124
|
+
MIDDLEWARE = [
|
|
125
|
+
"plain.postgres.DatabaseConnectionMiddleware",
|
|
126
|
+
# ...other middleware
|
|
127
|
+
]
|
|
88
128
|
```
|
|
89
129
|
|
|
90
|
-
|
|
130
|
+
Place it near the top so downstream middleware can use the database inside `before_request` / `after_response` and still have the connection returned cleanly at the end.
|
|
91
131
|
|
|
92
|
-
|
|
132
|
+
For `StreamingResponse` / `AsyncStreamingResponse`, the connection is returned after the body is fully drained (not when the view returns), so generators that lazily query the database — for example `Model.query.iterator()` or raw cursor loops — keep their cursor alive until the last chunk is sent.
|
|
93
133
|
|
|
94
|
-
|
|
134
|
+
Without the middleware, connections keep living on their thread until something explicitly calls `plain.postgres.db.return_database_connection()` (or the process exits). That's fine for short-lived scripts but wastes a connection per thread in long-running servers.
|
|
95
135
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
136
|
+
### Bypassing a connection pooler for management operations
|
|
137
|
+
|
|
138
|
+
Transaction-mode poolers (PlanetScale, Supabase's pooler, Neon's pooler, standalone pgbouncer in transaction mode) can't run DDL, long transactions, or `pg_dump`. To work around this, set a second URL that management commands use to reach Postgres directly:
|
|
139
|
+
|
|
140
|
+
```sh
|
|
141
|
+
PLAIN_POSTGRES_URL=postgresql://app@pooler:6432/myapp
|
|
142
|
+
PLAIN_POSTGRES_MANAGEMENT_URL=postgresql://app@postgres:5432/myapp
|
|
100
143
|
```
|
|
101
144
|
|
|
145
|
+
When `POSTGRES_MANAGEMENT_URL` is set, these commands connect through it instead of `POSTGRES_URL`:
|
|
146
|
+
|
|
147
|
+
- `plain migrations create`, `plain migrations apply`, `plain migrations list`, `plain migrations prune`, `plain migrations squash`
|
|
148
|
+
- `plain postgres sync`, `plain postgres converge`, `plain postgres schema`
|
|
149
|
+
- `plain postgres diagnose`, `plain postgres drop-unknown-tables`, `plain postgres shell`
|
|
150
|
+
|
|
151
|
+
When it's unset, all commands use `POSTGRES_URL` — there's no behavior change for existing apps.
|
|
152
|
+
|
|
153
|
+
To route custom code through the management connection, use the `use_management_connection()` context manager:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from plain.postgres import use_management_connection
|
|
157
|
+
|
|
158
|
+
with use_management_connection():
|
|
159
|
+
# Any get_connection() / ORM calls inside this block use POSTGRES_MANAGEMENT_URL.
|
|
160
|
+
run_custom_schema_change()
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
You _can_ point the two URLs at different Postgres roles — e.g. a least-privilege DML role for runtime and a DDL-capable role for management. Plain does not currently automate the grant/ownership plumbing that split requires (default privileges for newly-created tables, ownership reassignment, preflight checks that the runtime role can see the schema). If you adopt that pattern, you're responsible for wiring those up yourself.
|
|
164
|
+
|
|
102
165
|
## Querying
|
|
103
166
|
|
|
104
167
|
Models come with a powerful query API through their [`QuerySet`](./query.py#QuerySet) interface:
|
|
@@ -406,32 +469,34 @@ with transaction.atomic():
|
|
|
406
469
|
safe_operation() # This still runs in the outer transaction
|
|
407
470
|
```
|
|
408
471
|
|
|
409
|
-
### Read-only
|
|
472
|
+
### Read-only transactions
|
|
410
473
|
|
|
411
|
-
|
|
474
|
+
Run a block of code in a read-only transaction using `read_only()`. Any write (INSERT, UPDATE, DELETE, DDL) raises `psycopg.errors.ReadOnlySqlTransaction`:
|
|
412
475
|
|
|
413
476
|
```python
|
|
414
|
-
from plain.postgres.
|
|
477
|
+
from plain.postgres.db import read_only
|
|
415
478
|
|
|
416
479
|
with read_only():
|
|
417
480
|
users = User.query.all() # reads work
|
|
418
481
|
User.query.create(name="x") # raises psycopg.errors.ReadOnlySqlTransaction
|
|
419
482
|
```
|
|
420
483
|
|
|
421
|
-
|
|
484
|
+
`read_only()` opens a single `BEGIN READ ONLY` transaction for the block. Nested `atomic()` blocks inside become savepoints of the outer read-only transaction and inherit read-only.
|
|
422
485
|
|
|
423
|
-
|
|
486
|
+
Because it opens its own transaction, `read_only()` cannot be entered inside an existing `atomic()` block — doing so raises `TransactionManagementError`.
|
|
424
487
|
|
|
425
|
-
|
|
426
|
-
from plain.postgres.db import get_connection
|
|
488
|
+
Because the whole block is one transaction, catching a database error inside `read_only()` and trying to keep reading will fail — the transaction is aborted and any further query raises `TransactionManagementError`. Wrap the write in a nested `atomic()` savepoint if you need to recover and continue:
|
|
427
489
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
490
|
+
```python
|
|
491
|
+
with read_only():
|
|
492
|
+
try:
|
|
493
|
+
with atomic():
|
|
494
|
+
User.query.create(name="x") # raises, savepoint rolls back
|
|
495
|
+
except psycopg.errors.ReadOnlySqlTransaction:
|
|
496
|
+
pass
|
|
497
|
+
User.query.count() # still works — outer txn is healthy
|
|
431
498
|
```
|
|
432
499
|
|
|
433
|
-
Read-only mode must be set outside a transaction — calling it inside `atomic()` raises `TransactionManagementError`.
|
|
434
|
-
|
|
435
500
|
## Schema management
|
|
436
501
|
|
|
437
502
|
Schema changes fall into three categories, each with a different author and apply model:
|
|
@@ -1194,27 +1259,20 @@ These are static, code-level checks that catch issues before you deploy. The `di
|
|
|
1194
1259
|
|
|
1195
1260
|
## Settings
|
|
1196
1261
|
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
|
1204
|
-
|
|
|
1205
|
-
| `
|
|
1206
|
-
| `
|
|
1207
|
-
| `
|
|
1208
|
-
| `
|
|
1209
|
-
| `
|
|
1210
|
-
| `
|
|
1211
|
-
| `POSTGRES_CONN_HEALTH_CHECKS` | `bool` | `True` | `PLAIN_POSTGRES_CONN_HEALTH_CHECKS` |
|
|
1212
|
-
| `POSTGRES_OPTIONS` | `dict` | `{}` | — |
|
|
1213
|
-
| `POSTGRES_TIME_ZONE` | `str \| None` | `None` | `PLAIN_POSTGRES_TIME_ZONE` |
|
|
1214
|
-
| `POSTGRES_MIGRATION_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_LOCK_TIMEOUT` |
|
|
1215
|
-
| `POSTGRES_MIGRATION_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT` |
|
|
1216
|
-
| `POSTGRES_CONVERGENCE_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_LOCK_TIMEOUT` |
|
|
1217
|
-
| `POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` |
|
|
1262
|
+
The connection is configured with a single URL (`POSTGRES_URL`). `DATABASE_URL` is read as a platform-compat fallback. Set the URL to `none` to explicitly disable the database (e.g. during Docker image builds).
|
|
1263
|
+
|
|
1264
|
+
| Setting | Type | Default | Env var |
|
|
1265
|
+
| ---------------------------------------- | ------------- | ----------------------- | ---------------------------------------------- |
|
|
1266
|
+
| `POSTGRES_URL` | `Secret[str]` | `$DATABASE_URL` or `""` | `PLAIN_POSTGRES_URL` |
|
|
1267
|
+
| `POSTGRES_MANAGEMENT_URL` | `Secret[str]` | `""` | `PLAIN_POSTGRES_MANAGEMENT_URL` |
|
|
1268
|
+
| `POSTGRES_POOL_MIN_SIZE` | `int` | `4` | `PLAIN_POSTGRES_POOL_MIN_SIZE` |
|
|
1269
|
+
| `POSTGRES_POOL_MAX_SIZE` | `int` | `20` | `PLAIN_POSTGRES_POOL_MAX_SIZE` |
|
|
1270
|
+
| `POSTGRES_POOL_MAX_LIFETIME` | `float` | `3600.0` | `PLAIN_POSTGRES_POOL_MAX_LIFETIME` |
|
|
1271
|
+
| `POSTGRES_POOL_TIMEOUT` | `float` | `30.0` | `PLAIN_POSTGRES_POOL_TIMEOUT` |
|
|
1272
|
+
| `POSTGRES_MIGRATION_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_LOCK_TIMEOUT` |
|
|
1273
|
+
| `POSTGRES_MIGRATION_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT` |
|
|
1274
|
+
| `POSTGRES_CONVERGENCE_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_LOCK_TIMEOUT` |
|
|
1275
|
+
| `POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` |
|
|
1218
1276
|
|
|
1219
1277
|
See [`default_settings.py`](./default_settings.py) for more details.
|
|
1220
1278
|
|
|
@@ -1254,13 +1312,15 @@ Currently, Plain supports a single database connection per application. For appl
|
|
|
1254
1312
|
|
|
1255
1313
|
## Installation
|
|
1256
1314
|
|
|
1257
|
-
Install the `plain.postgres` package from [PyPI](https://pypi.org/project/plain.postgres/)
|
|
1315
|
+
Install the `plain.postgres` package from [PyPI](https://pypi.org/project/plain.postgres/). You must also pick a `psycopg` implementation — `plain.postgres` depends on `psycopg` but does not pick one for you, so installing `plain.postgres` alone will not be able to connect.
|
|
1258
1316
|
|
|
1259
1317
|
```bash
|
|
1260
|
-
uv add plain.postgres psycopg[binary]
|
|
1318
|
+
uv add plain.postgres psycopg[binary] # Pre-built wheels, easiest for local development
|
|
1319
|
+
# or
|
|
1320
|
+
uv add plain.postgres psycopg[c] # Compiled against your system's libpq, recommended for production
|
|
1261
1321
|
```
|
|
1262
1322
|
|
|
1263
|
-
Then add to your `INSTALLED_PACKAGES
|
|
1323
|
+
Then add to your `INSTALLED_PACKAGES` and register [`DatabaseConnectionMiddleware`](#middleware) so pooled connections are returned at the end of each request:
|
|
1264
1324
|
|
|
1265
1325
|
```python
|
|
1266
1326
|
# app/settings.py
|
|
@@ -1268,4 +1328,9 @@ INSTALLED_PACKAGES = [
|
|
|
1268
1328
|
...
|
|
1269
1329
|
"plain.postgres",
|
|
1270
1330
|
]
|
|
1331
|
+
|
|
1332
|
+
MIDDLEWARE = [
|
|
1333
|
+
"plain.postgres.DatabaseConnectionMiddleware",
|
|
1334
|
+
...
|
|
1335
|
+
]
|
|
1271
1336
|
```
|
|
@@ -1,5 +1,83 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
+
## [0.98.0](https://github.com/dropseed/plain/releases/plain-postgres@0.98.0) (2026-04-22)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- **Pool-backed connections via `psycopg_pool.ConnectionPool`.** A new `sources` abstraction routes `DatabaseConnection` through either a long-lived `PoolSource` (runtime) or a `DirectSource` (management / one-shot). Each request checks a connection out of the pool on first use and returns it when the HTTP request finishes. `psycopg>=3.2` and `psycopg-pool>=3.2` are now declared as hard dependencies. ([2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
|
|
8
|
+
- **New `DatabaseConnectionMiddleware` (required).** Add `"plain.postgres.DatabaseConnectionMiddleware"` to `MIDDLEWARE` — it's what returns the pooled connection at the end of each request. For `StreamingResponse` / `AsyncStreamingResponse` the connection is returned after the body fully drains, so generators that lazily query the database (e.g. `Model.query.iterator()`) keep their cursor alive until the last chunk is sent. A new `postgres.middleware_installed` preflight check errors if the middleware is missing. ([2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
|
|
9
|
+
- **Connection settings replaced with pool settings.** `POSTGRES_CONN_MAX_AGE` and `POSTGRES_CONN_HEALTH_CHECKS` are gone. Tune the pool with `POSTGRES_POOL_MIN_SIZE` (default `4`), `POSTGRES_POOL_MAX_SIZE` (default `20`), `POSTGRES_POOL_MAX_LIFETIME` seconds (default `3600.0`), and `POSTGRES_POOL_TIMEOUT` seconds (default `30.0`). Each is also available as a `PLAIN_POSTGRES_POOL_*` environment variable. ([2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
|
|
10
|
+
- **`plain.postgres.connections` module removed.** `get_connection`, `has_connection`, `use_management_connection`, and `read_only` now live in `plain.postgres.db` (the underscore-less counterpart). ([2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
|
|
11
|
+
- **`read_only()` is now pgbouncer-safe.** It opens a single `BEGIN READ ONLY` transaction for the block (previously a session-level `SET default_transaction_read_only = on`). Nested `atomic()` blocks become savepoints of the outer read-only transaction. Entering `read_only()` inside an existing `atomic()` block now raises `TransactionManagementError`. The old `DatabaseConnection.set_read_only()` method is removed. ([ebdec30](https://github.com/dropseed/plain/commit/ebdec30))
|
|
12
|
+
- **Added OTel pool + rowcount metrics and semconv polish.** Wires the `db.client.connection.*` metric family (count, max, idle.min/max, pending_requests, wait_time, use_time, timeouts) from the pool's stats and the acquire/release path, plus `db.client.response.returned_rows` for SELECT queries including streamed iterators. Query spans now carry `server.address` / `server.port` alongside `network.peer.*`, and the tracer/meter are tagged with the `plain.postgres` package version for `InstrumentationScope`. ([61278d5](https://github.com/dropseed/plain/commit/61278d5))
|
|
13
|
+
- **Moved `psql` CLI orchestration off `DatabaseConnection`.** New `postgres_cli_args` / `postgres_cli_env` helpers in `plain.postgres.database_url` build the arguments and environment for `psql`, `pg_dump`, etc.; `plain postgres shell` and the `plain-dev` backup client both use them. `DatabaseConnection.runshell()` and `executable_name` are gone. ([5b4a488](https://github.com/dropseed/plain/commit/5b4a488))
|
|
14
|
+
- **Removed dead connection-lifecycle plumbing.** `close_if_unusable_or_obsolete`, `close_if_health_check_failed`, `closed_in_transaction`, `is_usable`, `health_check_enabled`, `health_check_done`, `close_at`, `_maintenance_cursor`, and `DatabaseConnection.from_url` are gone — the pool handles recycling, health checks, and URL parsing. `close()` now validates there's no open atomic block instead of silently deferring. ([044e942](https://github.com/dropseed/plain/commit/044e942), [2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
|
|
15
|
+
- **Inlined `pg_version` and removed `temporary_connection()`.** The single caller now reads `connection.info.server_version` directly; `temporary_connection()` has no remaining users. ([319f6ac](https://github.com/dropseed/plain/commit/319f6ac))
|
|
16
|
+
- **`APIResult` shorthand returns moved out of `View`.** Any internal views that relied on dict/int shorthand now wrap their returns in `JsonResponse` / `Response(status_code=...)` to match plain 0.134.0's narrower `View` handler return type. ([1935f3f](https://github.com/dropseed/plain/commit/1935f3f))
|
|
17
|
+
- **Adapter registration extracted to `plain.postgres.adapters`.** `PlainRangeDumper` and `get_adapters_template()` moved out of `connection.py` into their own module.
|
|
18
|
+
|
|
19
|
+
### Upgrade instructions
|
|
20
|
+
|
|
21
|
+
- Requires `plain>=0.134.0`.
|
|
22
|
+
- **Add the middleware** to `app/settings.py`:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
MIDDLEWARE = [
|
|
26
|
+
"plain.postgres.DatabaseConnectionMiddleware",
|
|
27
|
+
# ...the rest of your middleware
|
|
28
|
+
]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Place it near the top so downstream middleware can use the database inside `before_request` / `after_response` and still have the connection returned cleanly. Preflight will error if it's missing.
|
|
32
|
+
|
|
33
|
+
- **Replace `POSTGRES_CONN_MAX_AGE` / `POSTGRES_CONN_HEALTH_CHECKS`** with the pool settings (`POSTGRES_POOL_MIN_SIZE`, `POSTGRES_POOL_MAX_SIZE`, `POSTGRES_POOL_MAX_LIFETIME`, `POSTGRES_POOL_TIMEOUT`) or remove them to take the defaults.
|
|
34
|
+
|
|
35
|
+
- **Update imports from `plain.postgres.connections`** to `plain.postgres.db`:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
# Before
|
|
39
|
+
from plain.postgres.connections import get_connection, read_only, use_management_connection
|
|
40
|
+
|
|
41
|
+
# After
|
|
42
|
+
from plain.postgres.db import get_connection, read_only, use_management_connection
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- **If you called `DatabaseConnection.set_read_only(True)`** for a sticky read-only session, switch to the `read_only()` context manager around the block you want read-only. If you need session-level enforcement outside a transaction, open a `DirectSource` connection yourself and issue `SET default_transaction_read_only = on` on it.
|
|
46
|
+
|
|
47
|
+
- **If you entered `read_only()` inside an `atomic()` block**, move `read_only()` to the outer position — it now owns the transaction. Nested `atomic()` blocks inside `read_only()` are fine (they become savepoints).
|
|
48
|
+
|
|
49
|
+
- **If you pinned `psycopg` via your own dependency**, make sure it's `>=3.2`, and add `psycopg-pool>=3.2` if you were installing psycopg without extras.
|
|
50
|
+
|
|
51
|
+
## [0.97.0](https://github.com/dropseed/plain/releases/plain-postgres@0.97.0) (2026-04-21)
|
|
52
|
+
|
|
53
|
+
### What's changed
|
|
54
|
+
|
|
55
|
+
- **Replaced individual `POSTGRES_*` connection fields with a single `POSTGRES_URL` setting.** `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DATABASE`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_OPTIONS`, and `POSTGRES_TIME_ZONE` are gone — configure the connection with one URL (e.g. `postgresql://user:pass@host:5432/db?sslmode=require`). `DATABASE_URL` is still read as a fallback. Set the URL to `none` to explicitly disable the database (e.g. during Docker image builds). ([770a74606463](https://github.com/dropseed/plain/commit/770a74606463))
|
|
56
|
+
- **Added `POSTGRES_MANAGEMENT_URL` for routing DDL through a separate connection.** When set, `plain migrations create|apply|list|prune|squash`, `plain postgres sync|converge|schema|diagnose|drop-unknown-tables|shell` connect through this URL instead of `POSTGRES_URL`. Use it to bypass transaction-mode poolers (PlanetScale, Supabase's pooler, Neon's pooler, pgbouncer) for schema changes, long transactions, and `pg_dump`. A new `use_management_connection()` context manager routes custom code through the same connection. When unset, all commands use `POSTGRES_URL` — no behavior change for existing apps. ([d1cc9630d049](https://github.com/dropseed/plain/commit/d1cc9630d049))
|
|
57
|
+
- **Extracted the test-database lifecycle off `DatabaseConnection`.** Test setup/teardown now lives in `plain.postgres.test` instead of coupling it to the runtime connection class. ([ea67f82c746c](https://github.com/dropseed/plain/commit/ea67f82c746c))
|
|
58
|
+
- **Removed thin psycopg re-export wrappers.** Internal code now imports directly from `psycopg` rather than the redundant Plain-level passthroughs. ([d1cb74100e0d](https://github.com/dropseed/plain/commit/d1cb74100e0d))
|
|
59
|
+
|
|
60
|
+
### Upgrade instructions
|
|
61
|
+
|
|
62
|
+
- **Replace individual `POSTGRES_*` settings with `POSTGRES_URL`** in `app/settings.py` (or `PLAIN_POSTGRES_URL` in the environment). For example:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
# Before
|
|
66
|
+
POSTGRES_HOST = "localhost"
|
|
67
|
+
POSTGRES_PORT = 5432
|
|
68
|
+
POSTGRES_DATABASE = "myapp"
|
|
69
|
+
POSTGRES_USER = "app"
|
|
70
|
+
POSTGRES_PASSWORD = "secret"
|
|
71
|
+
|
|
72
|
+
# After
|
|
73
|
+
POSTGRES_URL = "postgresql://app:secret@localhost:5432/myapp"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Apps that already set `DATABASE_URL` in the environment don't need any change.
|
|
77
|
+
|
|
78
|
+
- **If `POSTGRES_OPTIONS` or `POSTGRES_TIME_ZONE` were set**, move them into the URL as query parameters (e.g. `?application_name=web&timezone=UTC`).
|
|
79
|
+
- **If you run behind a transaction-mode pooler**, consider setting `POSTGRES_MANAGEMENT_URL` to a direct-to-Postgres connection string so `plain migrations` and `plain postgres sync` can issue DDL.
|
|
80
|
+
|
|
3
81
|
## [0.96.0](https://github.com/dropseed/plain/releases/plain-postgres@0.96.0) (2026-04-17)
|
|
4
82
|
|
|
5
83
|
### What's changed
|
|
@@ -1,21 +1,11 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: plain.postgres
|
|
3
|
-
Version: 0.96.0
|
|
4
|
-
Summary: Model your data and store it in a database.
|
|
5
|
-
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
|
-
License-Expression: BSD-3-Clause
|
|
7
|
-
License-File: LICENSE
|
|
8
|
-
Requires-Python: >=3.13
|
|
9
|
-
Requires-Dist: plain<1.0.0,>=0.132.0
|
|
10
|
-
Requires-Dist: sqlparse>=0.3.1
|
|
11
|
-
Description-Content-Type: text/markdown
|
|
12
|
-
|
|
13
1
|
# plain.postgres
|
|
14
2
|
|
|
15
3
|
**Model your data and store it in a database.**
|
|
16
4
|
|
|
17
5
|
- [Overview](#overview)
|
|
18
6
|
- [Database connection](#database-connection)
|
|
7
|
+
- [Middleware](#middleware)
|
|
8
|
+
- [Bypassing a connection pooler for management operations](#bypassing-a-connection-pooler-for-management-operations)
|
|
19
9
|
- [Querying](#querying)
|
|
20
10
|
- [Schema management](#schema-management)
|
|
21
11
|
- [Syncing](#syncing)
|
|
@@ -82,35 +72,82 @@ admin_users = User.query.filter(is_admin=True)
|
|
|
82
72
|
|
|
83
73
|
## Database connection
|
|
84
74
|
|
|
85
|
-
|
|
75
|
+
Configure the database with a single URL. The canonical Plain setting is `POSTGRES_URL`:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
# app/settings.py
|
|
79
|
+
POSTGRES_URL = "postgresql://user:password@localhost:5432/dbname"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Or via environment variable:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
PLAIN_POSTGRES_URL=postgresql://user:password@localhost:5432/dbname
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Plain also reads the `DATABASE_URL` environment variable as a fallback — it's the widely-used convention for Postgres connection strings, so most hosting setups work without extra configuration:
|
|
86
89
|
|
|
87
90
|
```sh
|
|
88
91
|
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
|
89
92
|
```
|
|
90
93
|
|
|
91
|
-
|
|
94
|
+
Precedence (highest to lowest): `PLAIN_POSTGRES_URL` → `POSTGRES_URL` in `settings.py` → `DATABASE_URL` environment variable.
|
|
95
|
+
|
|
96
|
+
The URL supports any libpq connection parameter as a query string — for example `?sslmode=require&application_name=web&connect_timeout=10`. These are parsed and passed through to the driver.
|
|
97
|
+
|
|
98
|
+
To explicitly disable the database (e.g. during Docker builds where no database is available), set the URL to the string `none`:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
PLAIN_POSTGRES_URL=none
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Middleware
|
|
105
|
+
|
|
106
|
+
Connections are checked out lazily on first use and returned to the pool when the HTTP request finishes. That's handled by [`DatabaseConnectionMiddleware`](./middleware.py#DatabaseConnectionMiddleware) — add it to `MIDDLEWARE` once you install `plain.postgres`:
|
|
92
107
|
|
|
93
108
|
```python
|
|
94
109
|
# app/settings.py
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
POSTGRES_PASSWORD = "password"
|
|
110
|
+
MIDDLEWARE = [
|
|
111
|
+
"plain.postgres.DatabaseConnectionMiddleware",
|
|
112
|
+
# ...other middleware
|
|
113
|
+
]
|
|
100
114
|
```
|
|
101
115
|
|
|
102
|
-
|
|
116
|
+
Place it near the top so downstream middleware can use the database inside `before_request` / `after_response` and still have the connection returned cleanly at the end.
|
|
103
117
|
|
|
104
|
-
|
|
118
|
+
For `StreamingResponse` / `AsyncStreamingResponse`, the connection is returned after the body is fully drained (not when the view returns), so generators that lazily query the database — for example `Model.query.iterator()` or raw cursor loops — keep their cursor alive until the last chunk is sent.
|
|
105
119
|
|
|
106
|
-
|
|
120
|
+
Without the middleware, connections keep living on their thread until something explicitly calls `plain.postgres.db.return_database_connection()` (or the process exits). That's fine for short-lived scripts but wastes a connection per thread in long-running servers.
|
|
107
121
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
122
|
+
### Bypassing a connection pooler for management operations
|
|
123
|
+
|
|
124
|
+
Transaction-mode poolers (PlanetScale, Supabase's pooler, Neon's pooler, standalone pgbouncer in transaction mode) can't run DDL, long transactions, or `pg_dump`. To work around this, set a second URL that management commands use to reach Postgres directly:
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
PLAIN_POSTGRES_URL=postgresql://app@pooler:6432/myapp
|
|
128
|
+
PLAIN_POSTGRES_MANAGEMENT_URL=postgresql://app@postgres:5432/myapp
|
|
112
129
|
```
|
|
113
130
|
|
|
131
|
+
When `POSTGRES_MANAGEMENT_URL` is set, these commands connect through it instead of `POSTGRES_URL`:
|
|
132
|
+
|
|
133
|
+
- `plain migrations create`, `plain migrations apply`, `plain migrations list`, `plain migrations prune`, `plain migrations squash`
|
|
134
|
+
- `plain postgres sync`, `plain postgres converge`, `plain postgres schema`
|
|
135
|
+
- `plain postgres diagnose`, `plain postgres drop-unknown-tables`, `plain postgres shell`
|
|
136
|
+
|
|
137
|
+
When it's unset, all commands use `POSTGRES_URL` — there's no behavior change for existing apps.
|
|
138
|
+
|
|
139
|
+
To route custom code through the management connection, use the `use_management_connection()` context manager:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from plain.postgres import use_management_connection
|
|
143
|
+
|
|
144
|
+
with use_management_connection():
|
|
145
|
+
# Any get_connection() / ORM calls inside this block use POSTGRES_MANAGEMENT_URL.
|
|
146
|
+
run_custom_schema_change()
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
You _can_ point the two URLs at different Postgres roles — e.g. a least-privilege DML role for runtime and a DDL-capable role for management. Plain does not currently automate the grant/ownership plumbing that split requires (default privileges for newly-created tables, ownership reassignment, preflight checks that the runtime role can see the schema). If you adopt that pattern, you're responsible for wiring those up yourself.
|
|
150
|
+
|
|
114
151
|
## Querying
|
|
115
152
|
|
|
116
153
|
Models come with a powerful query API through their [`QuerySet`](./query.py#QuerySet) interface:
|
|
@@ -418,32 +455,34 @@ with transaction.atomic():
|
|
|
418
455
|
safe_operation() # This still runs in the outer transaction
|
|
419
456
|
```
|
|
420
457
|
|
|
421
|
-
### Read-only
|
|
458
|
+
### Read-only transactions
|
|
422
459
|
|
|
423
|
-
|
|
460
|
+
Run a block of code in a read-only transaction using `read_only()`. Any write (INSERT, UPDATE, DELETE, DDL) raises `psycopg.errors.ReadOnlySqlTransaction`:
|
|
424
461
|
|
|
425
462
|
```python
|
|
426
|
-
from plain.postgres.
|
|
463
|
+
from plain.postgres.db import read_only
|
|
427
464
|
|
|
428
465
|
with read_only():
|
|
429
466
|
users = User.query.all() # reads work
|
|
430
467
|
User.query.create(name="x") # raises psycopg.errors.ReadOnlySqlTransaction
|
|
431
468
|
```
|
|
432
469
|
|
|
433
|
-
|
|
470
|
+
`read_only()` opens a single `BEGIN READ ONLY` transaction for the block. Nested `atomic()` blocks inside become savepoints of the outer read-only transaction and inherit read-only.
|
|
434
471
|
|
|
435
|
-
|
|
472
|
+
Because it opens its own transaction, `read_only()` cannot be entered inside an existing `atomic()` block — doing so raises `TransactionManagementError`.
|
|
436
473
|
|
|
437
|
-
|
|
438
|
-
from plain.postgres.db import get_connection
|
|
474
|
+
Because the whole block is one transaction, catching a database error inside `read_only()` and trying to keep reading will fail — the transaction is aborted and any further query raises `TransactionManagementError`. Wrap the write in a nested `atomic()` savepoint if you need to recover and continue:
|
|
439
475
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
476
|
+
```python
|
|
477
|
+
with read_only():
|
|
478
|
+
try:
|
|
479
|
+
with atomic():
|
|
480
|
+
User.query.create(name="x") # raises, savepoint rolls back
|
|
481
|
+
except psycopg.errors.ReadOnlySqlTransaction:
|
|
482
|
+
pass
|
|
483
|
+
User.query.count() # still works — outer txn is healthy
|
|
443
484
|
```
|
|
444
485
|
|
|
445
|
-
Read-only mode must be set outside a transaction — calling it inside `atomic()` raises `TransactionManagementError`.
|
|
446
|
-
|
|
447
486
|
## Schema management
|
|
448
487
|
|
|
449
488
|
Schema changes fall into three categories, each with a different author and apply model:
|
|
@@ -1206,27 +1245,20 @@ These are static, code-level checks that catch issues before you deploy. The `di
|
|
|
1206
1245
|
|
|
1207
1246
|
## Settings
|
|
1208
1247
|
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
|
1216
|
-
|
|
|
1217
|
-
| `
|
|
1218
|
-
| `
|
|
1219
|
-
| `
|
|
1220
|
-
| `
|
|
1221
|
-
| `
|
|
1222
|
-
| `
|
|
1223
|
-
| `POSTGRES_CONN_HEALTH_CHECKS` | `bool` | `True` | `PLAIN_POSTGRES_CONN_HEALTH_CHECKS` |
|
|
1224
|
-
| `POSTGRES_OPTIONS` | `dict` | `{}` | — |
|
|
1225
|
-
| `POSTGRES_TIME_ZONE` | `str \| None` | `None` | `PLAIN_POSTGRES_TIME_ZONE` |
|
|
1226
|
-
| `POSTGRES_MIGRATION_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_LOCK_TIMEOUT` |
|
|
1227
|
-
| `POSTGRES_MIGRATION_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT` |
|
|
1228
|
-
| `POSTGRES_CONVERGENCE_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_LOCK_TIMEOUT` |
|
|
1229
|
-
| `POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` |
|
|
1248
|
+
The connection is configured with a single URL (`POSTGRES_URL`). `DATABASE_URL` is read as a platform-compat fallback. Set the URL to `none` to explicitly disable the database (e.g. during Docker image builds).
|
|
1249
|
+
|
|
1250
|
+
| Setting | Type | Default | Env var |
|
|
1251
|
+
| ---------------------------------------- | ------------- | ----------------------- | ---------------------------------------------- |
|
|
1252
|
+
| `POSTGRES_URL` | `Secret[str]` | `$DATABASE_URL` or `""` | `PLAIN_POSTGRES_URL` |
|
|
1253
|
+
| `POSTGRES_MANAGEMENT_URL` | `Secret[str]` | `""` | `PLAIN_POSTGRES_MANAGEMENT_URL` |
|
|
1254
|
+
| `POSTGRES_POOL_MIN_SIZE` | `int` | `4` | `PLAIN_POSTGRES_POOL_MIN_SIZE` |
|
|
1255
|
+
| `POSTGRES_POOL_MAX_SIZE` | `int` | `20` | `PLAIN_POSTGRES_POOL_MAX_SIZE` |
|
|
1256
|
+
| `POSTGRES_POOL_MAX_LIFETIME` | `float` | `3600.0` | `PLAIN_POSTGRES_POOL_MAX_LIFETIME` |
|
|
1257
|
+
| `POSTGRES_POOL_TIMEOUT` | `float` | `30.0` | `PLAIN_POSTGRES_POOL_TIMEOUT` |
|
|
1258
|
+
| `POSTGRES_MIGRATION_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_LOCK_TIMEOUT` |
|
|
1259
|
+
| `POSTGRES_MIGRATION_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT` |
|
|
1260
|
+
| `POSTGRES_CONVERGENCE_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_LOCK_TIMEOUT` |
|
|
1261
|
+
| `POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` |
|
|
1230
1262
|
|
|
1231
1263
|
See [`default_settings.py`](./default_settings.py) for more details.
|
|
1232
1264
|
|
|
@@ -1266,13 +1298,15 @@ Currently, Plain supports a single database connection per application. For appl
|
|
|
1266
1298
|
|
|
1267
1299
|
## Installation
|
|
1268
1300
|
|
|
1269
|
-
Install the `plain.postgres` package from [PyPI](https://pypi.org/project/plain.postgres/)
|
|
1301
|
+
Install the `plain.postgres` package from [PyPI](https://pypi.org/project/plain.postgres/). You must also pick a `psycopg` implementation — `plain.postgres` depends on `psycopg` but does not pick one for you, so installing `plain.postgres` alone will not be able to connect.
|
|
1270
1302
|
|
|
1271
1303
|
```bash
|
|
1272
|
-
uv add plain.postgres psycopg[binary]
|
|
1304
|
+
uv add plain.postgres psycopg[binary] # Pre-built wheels, easiest for local development
|
|
1305
|
+
# or
|
|
1306
|
+
uv add plain.postgres psycopg[c] # Compiled against your system's libpq, recommended for production
|
|
1273
1307
|
```
|
|
1274
1308
|
|
|
1275
|
-
Then add to your `INSTALLED_PACKAGES
|
|
1309
|
+
Then add to your `INSTALLED_PACKAGES` and register [`DatabaseConnectionMiddleware`](#middleware) so pooled connections are returned at the end of each request:
|
|
1276
1310
|
|
|
1277
1311
|
```python
|
|
1278
1312
|
# app/settings.py
|
|
@@ -1280,4 +1314,9 @@ INSTALLED_PACKAGES = [
|
|
|
1280
1314
|
...
|
|
1281
1315
|
"plain.postgres",
|
|
1282
1316
|
]
|
|
1317
|
+
|
|
1318
|
+
MIDDLEWARE = [
|
|
1319
|
+
"plain.postgres.DatabaseConnectionMiddleware",
|
|
1320
|
+
...
|
|
1321
|
+
]
|
|
1283
1322
|
```
|
|
@@ -6,7 +6,8 @@ from . import (
|
|
|
6
6
|
# Imports that would create circular imports if sorted
|
|
7
7
|
from .base import Model
|
|
8
8
|
from .constraints import CheckConstraint, UniqueConstraint
|
|
9
|
-
from .db import get_connection
|
|
9
|
+
from .db import get_connection, use_management_connection
|
|
10
|
+
from .middleware import DatabaseConnectionMiddleware
|
|
10
11
|
from .deletion import CASCADE, NO_ACTION, RESTRICT, SET_NULL
|
|
11
12
|
from .expressions import F
|
|
12
13
|
from .enums import TextChoices
|
|
@@ -104,6 +105,9 @@ __all__ = [
|
|
|
104
105
|
"ReverseManyToMany",
|
|
105
106
|
# From db
|
|
106
107
|
"get_connection",
|
|
108
|
+
"use_management_connection",
|
|
109
|
+
# From middleware
|
|
110
|
+
"DatabaseConnectionMiddleware",
|
|
107
111
|
# From registry
|
|
108
112
|
"register_model",
|
|
109
113
|
"models_registry",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Psycopg adapter registration for Plain.
|
|
2
|
+
|
|
3
|
+
The `AdaptersMap` returned by `get_adapters_template()` is attached to every
|
|
4
|
+
psycopg connection we open (via `build_connection_params` in `sources.py`).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from psycopg import adapt, adapters
|
|
13
|
+
from psycopg.abc import PyFormat
|
|
14
|
+
from psycopg.postgres import types as pg_types
|
|
15
|
+
from psycopg.types.range import BaseRangeDumper, Range, RangeDumper
|
|
16
|
+
from psycopg.types.string import TextLoader
|
|
17
|
+
|
|
18
|
+
TSRANGE_OID = pg_types["tsrange"].oid
|
|
19
|
+
TSTZRANGE_OID = pg_types["tstzrange"].oid
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PlainRangeDumper(RangeDumper):
|
|
23
|
+
"""A Range dumper customized for Plain."""
|
|
24
|
+
|
|
25
|
+
def upgrade(self, obj: Range[Any], format: PyFormat) -> BaseRangeDumper:
|
|
26
|
+
dumper = super().upgrade(obj, format)
|
|
27
|
+
if dumper is not self and dumper.oid == TSRANGE_OID:
|
|
28
|
+
dumper.oid = TSTZRANGE_OID
|
|
29
|
+
return dumper
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@lru_cache
|
|
33
|
+
def get_adapters_template() -> adapt.AdaptersMap:
|
|
34
|
+
ctx = adapt.AdaptersMap(adapters)
|
|
35
|
+
# No-op JSON loader to avoid psycopg3 round trips
|
|
36
|
+
ctx.register_loader("jsonb", TextLoader)
|
|
37
|
+
# Treat inet/cidr as text
|
|
38
|
+
ctx.register_loader("inet", TextLoader)
|
|
39
|
+
ctx.register_loader("cidr", TextLoader)
|
|
40
|
+
ctx.register_dumper(Range, PlainRangeDumper)
|
|
41
|
+
return ctx
|